From bb7227d18d574af35871c2d2f2a2d1310932e0ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Fri, 7 Jun 2024 15:30:26 +0200 Subject: [PATCH 01/20] fix(editor): Indent on tabs in expression fields (#9659) --- .../InlineExpressionEditorInput.vue | 2 +- .../__tests__/useExpressionEditor.test.ts | 31 ++++++++++++++++++- .../src/plugins/codemirror/keymap.ts | 4 +-- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue index c6ed0e1e44f23..6b33cce6f3a2d 100644 --- a/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue +++ b/packages/editor-ui/src/components/InlineExpressionEditor/InlineExpressionEditorInput.vue @@ -49,7 +49,7 @@ const ndvStore = useNDVStore(); const root = ref(); const extensions = computed(() => [ Prec.highest( - keymap.of([...tabKeyMap(true), ...enterKeyMap, ...autocompleteKeyMap, ...historyKeyMap]), + keymap.of([...tabKeyMap(false), ...enterKeyMap, ...autocompleteKeyMap, ...historyKeyMap]), ), n8nLang(), n8nAutocompletion(), diff --git a/packages/editor-ui/src/composables/__tests__/useExpressionEditor.test.ts b/packages/editor-ui/src/composables/__tests__/useExpressionEditor.test.ts index 35ceb88bde588..958946d2adb96 100644 --- a/packages/editor-ui/src/composables/__tests__/useExpressionEditor.test.ts +++ b/packages/editor-ui/src/composables/__tests__/useExpressionEditor.test.ts @@ -1,5 +1,5 @@ import * as workflowHelpers from '@/composables/useWorkflowHelpers'; -import { EditorView } from '@codemirror/view'; +import { EditorView, keymap } from '@codemirror/view'; import { createTestingPinia } from '@pinia/testing'; import { waitFor, fireEvent } from '@testing-library/vue'; import { setActivePinia } from 'pinia'; @@ -11,6 +11,7 @@ import { useRouter } from 'vue-router'; import { EditorSelection } from '@codemirror/state'; import userEvent from '@testing-library/user-event'; import { renderComponent } from '@/__tests__/render'; +import { tabKeyMap } from '@/plugins/codemirror/keymap'; vi.mock('@/composables/useAutocompleteTelemetry', () => ({ useAutocompleteTelemetry: vi.fn(), @@ -254,4 +255,32 @@ describe('useExpressionEditor', () => { expect(expressionEditor.editor.value?.hasFocus).toBe(true); }); }); + + describe('keymap', () => { + const TEST_EDITOR_VALUE = '{{ { "foo": "bar" } }}'; + + test('should indent on tab if blurOnTab is false', async () => { + const { renderResult, expressionEditor } = await renderExpressionEditor({ + editorValue: TEST_EDITOR_VALUE, + extensions: [keymap.of([...tabKeyMap(false)])], + }); + const root = renderResult.getByTestId('editor-root'); + const input = root.querySelector('.cm-line') as HTMLDivElement; + + await userEvent.type(input, '{tab}'); + expect(expressionEditor.editor.value?.state.doc.toString()).toEqual(` ${TEST_EDITOR_VALUE}`); + }); + + test('should NOT indent on tab if blurOnTab is true', async () => { + const { renderResult, expressionEditor } = await renderExpressionEditor({ + editorValue: TEST_EDITOR_VALUE, + extensions: [keymap.of([...tabKeyMap(true)])], + }); + const root = renderResult.getByTestId('editor-root'); + const input = root.querySelector('.cm-line') as HTMLDivElement; + + await userEvent.type(input, '{tab}'); + expect(expressionEditor.editor.value?.state.doc.toString()).toEqual(TEST_EDITOR_VALUE); + }); + }); }); diff --git a/packages/editor-ui/src/plugins/codemirror/keymap.ts b/packages/editor-ui/src/plugins/codemirror/keymap.ts index f1d9316491584..46e8e284d8128 100644 --- a/packages/editor-ui/src/plugins/codemirror/keymap.ts +++ b/packages/editor-ui/src/plugins/codemirror/keymap.ts @@ -7,7 +7,7 @@ import { import { indentLess, indentMore, insertNewlineAndIndent, redo, undo } from '@codemirror/commands'; import type { EditorView, KeyBinding } from '@codemirror/view'; -export const tabKeyMap = (singleLine = false): KeyBinding[] => [ +export const tabKeyMap = (blurOnTab = false): KeyBinding[] => [ { any(view, event) { if ( @@ -27,7 +27,7 @@ export const tabKeyMap = (singleLine = false): KeyBinding[] => [ return acceptCompletion(view); } - if (!singleLine) return indentMore(view); + if (!blurOnTab) return indentMore(view); return false; }, }, From 291d46af155cd5c512f5e7d4597e31d7ea02bc54 Mon Sep 17 00:00:00 2001 From: Elias Meire Date: Fri, 7 Jun 2024 15:37:30 +0200 Subject: [PATCH 02/20] fix(editor): Improve dragndrop of input pills with spaces (#9656) --- .../AssignmentCollection.vue | 7 +- .../__tests__/utils.test.ts | 15 ---- .../components/AssignmentCollection/utils.ts | 7 -- .../src/utils/__tests__/mappingUtils.test.ts | 90 +++++++++++++++++-- packages/editor-ui/src/utils/mappingUtils.ts | 53 +++++++---- 5 files changed, 124 insertions(+), 48 deletions(-) delete mode 100644 packages/editor-ui/src/components/AssignmentCollection/__tests__/utils.test.ts diff --git a/packages/editor-ui/src/components/AssignmentCollection/AssignmentCollection.vue b/packages/editor-ui/src/components/AssignmentCollection/AssignmentCollection.vue index 7336684f6c8a7..ed641a600f0bb 100644 --- a/packages/editor-ui/src/components/AssignmentCollection/AssignmentCollection.vue +++ b/packages/editor-ui/src/components/AssignmentCollection/AssignmentCollection.vue @@ -13,7 +13,8 @@ import { computed, reactive, watch } from 'vue'; import DropArea from '../DropArea/DropArea.vue'; import ParameterOptions from '../ParameterOptions.vue'; import Assignment from './Assignment.vue'; -import { inputDataToAssignments, nameFromExpression, typeFromExpression } from './utils'; +import { inputDataToAssignments, typeFromExpression } from './utils'; +import { propertyNameFromExpression } from '@/utils/mappingUtils'; interface Props { parameter: INodeProperties; @@ -49,7 +50,7 @@ const issues = computed(() => { }); const empty = computed(() => state.paramValue.assignments.length === 0); -const activeDragField = computed(() => nameFromExpression(ndvStore.draggableData)); +const activeDragField = computed(() => propertyNameFromExpression(ndvStore.draggableData)); const inputData = computed(() => ndvStore.ndvInputData?.[0]?.json); const actions = computed(() => { return [ @@ -82,7 +83,7 @@ function addAssignment(): void { function dropAssignment(expression: string): void { state.paramValue.assignments.push({ id: uuid(), - name: nameFromExpression(expression), + name: propertyNameFromExpression(expression), value: `=${expression}`, type: typeFromExpression(expression), }); diff --git a/packages/editor-ui/src/components/AssignmentCollection/__tests__/utils.test.ts b/packages/editor-ui/src/components/AssignmentCollection/__tests__/utils.test.ts deleted file mode 100644 index 7767a2a4bad6b..0000000000000 --- a/packages/editor-ui/src/components/AssignmentCollection/__tests__/utils.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { nameFromExpression } from '../utils'; - -describe('AssignmentCollection > utils', () => { - describe('nameFromExpression', () => { - test('should extract assignment name from previous node', () => { - expect(nameFromExpression('{{ $json.foo.bar }}')).toBe('foo.bar'); - }); - - test('should extract assignment name from another node', () => { - expect(nameFromExpression("{{ $('Node's \"Name\" (copy)').item.json.foo.bar }}")).toBe( - 'foo.bar', - ); - }); - }); -}); diff --git a/packages/editor-ui/src/components/AssignmentCollection/utils.ts b/packages/editor-ui/src/components/AssignmentCollection/utils.ts index 55c7556fb73e7..ef6cdd3b94c96 100644 --- a/packages/editor-ui/src/components/AssignmentCollection/utils.ts +++ b/packages/editor-ui/src/components/AssignmentCollection/utils.ts @@ -3,13 +3,6 @@ import type { AssignmentValue, IDataObject } from 'n8n-workflow'; import { resolveParameter } from '@/composables/useWorkflowHelpers'; import { v4 as uuid } from 'uuid'; -export function nameFromExpression(expression: string): string { - return expression - .replace(/^{{\s*|\s*}}$/g, '') - .replace('$json.', '') - .replace(/^\$\(.*\)(\.item\.json)?\.(.*)/, '$2'); -} - export function inferAssignmentType(value: unknown): string { if (typeof value === 'boolean') return 'boolean'; if (typeof value === 'number') return 'number'; diff --git a/packages/editor-ui/src/utils/__tests__/mappingUtils.test.ts b/packages/editor-ui/src/utils/__tests__/mappingUtils.test.ts index 8dfc75ba1ba83..797c5127681da 100644 --- a/packages/editor-ui/src/utils/__tests__/mappingUtils.test.ts +++ b/packages/editor-ui/src/utils/__tests__/mappingUtils.test.ts @@ -1,5 +1,10 @@ import type { INodeProperties } from 'n8n-workflow'; -import { getMappedResult, getMappedExpression, escapeMappingString } from '../mappingUtils'; +import { + getMappedResult, + getMappedExpression, + escapeMappingString, + propertyNameFromExpression, +} from '../mappingUtils'; const RLC_PARAM: INodeProperties = { displayName: 'Base', @@ -146,7 +151,7 @@ describe('Mapping Utils', () => { it('sets data path, replacing if expecting single path', () => { expect( getMappedResult(SINGLE_DATA_PATH_PARAM, '{{ $json["Readable date"] }}', '={{$json.test}}'), - ).toEqual('["Readable date"]'); + ).toEqual('Readable date'); expect( getMappedResult(SINGLE_DATA_PATH_PARAM, '{{ $json.path }}', '={{$json.test}}'), @@ -159,18 +164,26 @@ describe('Mapping Utils', () => { ).toEqual('path, ["Readable date"]'); }); - it('replaces existing dadata path if multiple and is empty expression', () => { + it('replaces existing data path if multiple and is empty expression', () => { expect(getMappedResult(MULTIPLE_DATA_PATH_PARAM, '{{ $json.test }}', '=')).toEqual('test'); }); - it('handles data when dragging from grand-parent nodes', () => { + it('handles data when dragging from grand-parent nodes, replacing if expecting single path', () => { expect( getMappedResult( MULTIPLE_DATA_PATH_PARAM, '{{ $node["Schedule Trigger"].json["Day of week"] }}', '', ), - ).toEqual('={{ $node["Schedule Trigger"].json["Day of week"] }}'); + ).toEqual('["Day of week"]'); + + expect( + getMappedResult( + MULTIPLE_DATA_PATH_PARAM, + '{{ $node["Schedule Trigger"].json["Day of week"] }}', + '=data', + ), + ).toEqual('=data, ["Day of week"]'); expect( getMappedResult( @@ -178,7 +191,7 @@ describe('Mapping Utils', () => { '{{ $node["Schedule Trigger"].json["Day of week"] }}', '=data', ), - ).toEqual('=data {{ $node["Schedule Trigger"].json["Day of week"] }}'); + ).toEqual('Day of week'); expect( getMappedResult( @@ -186,7 +199,7 @@ describe('Mapping Utils', () => { '{{ $node["Schedule Trigger"].json["Day of week"] }}', '= ', ), - ).toEqual('= {{ $node["Schedule Trigger"].json["Day of week"] }}'); + ).toEqual('Day of week'); }); it('handles RLC values', () => { @@ -195,6 +208,7 @@ describe('Mapping Utils', () => { expect(getMappedResult(RLC_PARAM, '{{ test }}', '=test')).toEqual('=test {{ test }}'); }); }); + describe('getMappedExpression', () => { it('should generate a mapped expression with simple array path', () => { const input = { @@ -274,6 +288,68 @@ describe('Mapping Utils', () => { ); }); }); + + describe('propertyNameFromExpression', () => { + describe('dot access', () => { + test('should extract property name from previous node', () => { + expect(propertyNameFromExpression('{{ $json.foo.bar }}')).toBe('foo.bar'); + }); + + test('should extract property name from another node', () => { + expect( + propertyNameFromExpression("{{ $('Node's \"Name\" (copy)').item.json.foo.bar }}"), + ).toBe('foo.bar'); + }); + }); + + describe('bracket access', () => { + test('should extract property name from previous node (root)', () => { + expect(propertyNameFromExpression("{{ $json['with spaces\\' here'] }}")).toBe( + "with spaces' here", + ); + }); + + test('should extract property name from previous node (nested)', () => { + expect(propertyNameFromExpression("{{ $json.foo['with spaces\\' here'] }}")).toBe( + "foo['with spaces\\' here']", + ); + }); + + test('should extract property name from another node (root)', () => { + expect( + propertyNameFromExpression( + "{{ $('Node's \"Name\" (copy)').item.json['with spaces\\' here'] }}", + ), + ).toBe("with spaces' here"); + }); + + test('should extract property name from another node (nested)', () => { + expect( + propertyNameFromExpression( + "{{ $('Node's \"Name\" (copy)').item.json.foo['with spaces\\' here'] }}", + ), + ).toBe("foo['with spaces\\' here']"); + }); + + test('should handle nested bracket access', () => { + expect( + propertyNameFromExpression( + "{{ $('Node's \"Name\" (copy)').item.json['First with spaces']['Second with spaces'] }}", + ), + ).toBe("['First with spaces']['Second with spaces']"); + }); + + test('should handle forceBracketAccess=true', () => { + expect( + propertyNameFromExpression( + "{{ $('Node's \"Name\" (copy)').item.json['First with spaces'] }}", + true, + ), + ).toBe("['First with spaces']"); + }); + }); + }); + describe('escapeMappingString', () => { test.each([ { input: 'Normal node name (here)', output: 'Normal node name (here)' }, diff --git a/packages/editor-ui/src/utils/mappingUtils.ts b/packages/editor-ui/src/utils/mappingUtils.ts index b743f2265d474..bf1c2a9ec677d 100644 --- a/packages/editor-ui/src/utils/mappingUtils.ts +++ b/packages/editor-ui/src/utils/mappingUtils.ts @@ -1,5 +1,6 @@ import type { INodeProperties, NodeParameterValueType } from 'n8n-workflow'; import { isResourceLocatorValue } from 'n8n-workflow'; +import { isExpression } from './expressions'; const validJsIdNameRegex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; @@ -46,34 +47,54 @@ export function getMappedExpression({ return `{{ ${generatePath(root, path)} }}`; } +const unquote = (str: string) => { + if (str.startsWith('"') && str.endsWith('"')) { + return str.slice(1, -1).replace(/\\"/g, '"'); + } + + if (str.startsWith("'") && str.endsWith("'")) { + return str.slice(1, -1).replace(/\\'/g, "'"); + } + + return str; +}; + +export function propertyNameFromExpression(expression: string, forceBracketAccess = false): string { + const propPath = expression + .replace(/^{{\s*|\s*}}$/g, '') + .replace(/^(\$\(.*\)\.item\.json|\$json|\$node\[.*\]\.json)\.?(.*)/, '$2'); + + const isSingleBracketAccess = propPath.startsWith('[') && !propPath.slice(1).includes('['); + if (isSingleBracketAccess && !forceBracketAccess) { + // "['Key with spaces']" -> "Key with spaces" + return unquote(propPath.slice(1, -1)); + } + + return propPath; +} + export function getMappedResult( parameter: INodeProperties, newParamValue: string, prevParamValue: NodeParameterValueType, ): string { - const useDataPath = !!parameter.requiresDataPath && newParamValue.startsWith('{{ $json'); // ignore when mapping from grand-parent-node const prevValue = parameter.type === 'resourceLocator' && isResourceLocatorValue(prevParamValue) ? prevParamValue.value : prevParamValue; - if (useDataPath) { - const newValue = newParamValue - .replace('{{ $json', '') - .replace(new RegExp('^\\.'), '') - .replace(new RegExp('}}$'), '') - .trim(); - - if (prevValue && parameter.requiresDataPath === 'multiple') { - if (typeof prevValue === 'string' && prevValue.trim() === '=') { - return newValue; - } else { - return `${prevValue}, ${newValue}`; + if (parameter.requiresDataPath) { + if (parameter.requiresDataPath === 'multiple') { + const propertyName = propertyNameFromExpression(newParamValue, true); + if (typeof prevValue === 'string' && (prevValue.trim() === '=' || prevValue.trim() === '')) { + return propertyName; } - } else { - return newValue; + + return `${prevValue}, ${propertyName}`; } - } else if (typeof prevValue === 'string' && prevValue.startsWith('=') && prevValue.length > 1) { + + return propertyNameFromExpression(newParamValue); + } else if (typeof prevValue === 'string' && isExpression(prevValue) && prevValue.length > 1) { return `${prevValue} ${newParamValue}`; } else if (prevValue && ['string', 'json'].includes(parameter.type)) { return prevValue === '=' ? `=${newParamValue}` : `=${prevValue} ${newParamValue}`; From b8338e36105446ec386e2567f4be74594aa8497a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Fri, 7 Jun 2024 16:19:59 +0200 Subject: [PATCH 03/20] refactor(core): Revamp crash recovery mechanism for main mode (#9613) --- packages/cli/src/constants.ts | 11 + packages/cli/src/errors/node-crashed.error.ts | 12 + .../cli/src/errors/workflow-crashed.error.ts | 7 + .../MessageEventBus/MessageEventBus.ts | 7 +- .../eventbus/executionDataRecovery.service.ts | 213 --------- .../execution-recovery.service.test.ts | 420 ++++++++++++++++++ .../executions/execution-recovery.service.ts | 189 ++++++++ .../cli/test/integration/eventbus.ee.test.ts | 4 +- .../cli/test/integration/eventbus.test.ts | 4 +- packages/cli/test/integration/metrics.test.ts | 4 +- 10 files changed, 648 insertions(+), 223 deletions(-) create mode 100644 packages/cli/src/errors/node-crashed.error.ts create mode 100644 packages/cli/src/errors/workflow-crashed.error.ts delete mode 100644 packages/cli/src/eventbus/executionDataRecovery.service.ts create mode 100644 packages/cli/src/executions/__tests__/execution-recovery.service.test.ts create mode 100644 packages/cli/src/executions/execution-recovery.service.ts diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index aac5ac9f19560..7c1e7f69c511c 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -156,3 +156,14 @@ export const GENERIC_OAUTH2_CREDENTIALS_WITH_EDITABLE_SCOPE = [ 'microsoftOAuth2Api', 'highLevelOAuth2Api', ]; + +export const ARTIFICIAL_TASK_DATA = { + main: [ + [ + { + json: { isArtificialRecoveredEventItem: true }, + pairedItem: undefined, + }, + ], + ], +}; diff --git a/packages/cli/src/errors/node-crashed.error.ts b/packages/cli/src/errors/node-crashed.error.ts new file mode 100644 index 0000000000000..24433ff979e6d --- /dev/null +++ b/packages/cli/src/errors/node-crashed.error.ts @@ -0,0 +1,12 @@ +import type { INode } from 'n8n-workflow'; +import { NodeOperationError } from 'n8n-workflow'; + +export class NodeCrashedError extends NodeOperationError { + constructor(node: INode) { + super(node, 'Node crashed, possible out-of-memory issue', { + message: 'Execution stopped at this node', + description: + "n8n may have run out of memory while running this execution. More context and tips on how to avoid this in the docs", + }); + } +} diff --git a/packages/cli/src/errors/workflow-crashed.error.ts b/packages/cli/src/errors/workflow-crashed.error.ts new file mode 100644 index 0000000000000..122dd70ef286b --- /dev/null +++ b/packages/cli/src/errors/workflow-crashed.error.ts @@ -0,0 +1,7 @@ +import { WorkflowOperationError } from 'n8n-workflow'; + +export class WorkflowCrashedError extends WorkflowOperationError { + constructor() { + super('Workflow did not finish, possible out-of-memory issue'); + } +} diff --git a/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts b/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts index 6bc7d7b6a7d9c..141719c68d69e 100644 --- a/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts +++ b/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts @@ -32,7 +32,7 @@ import { import { METRICS_EVENT_NAME } from '../MessageEventBusDestination/Helpers.ee'; import type { AbstractEventMessageOptions } from '../EventMessageClasses/AbstractEventMessageOptions'; import { getEventMessageObjectByType } from '../EventMessageClasses/Helpers'; -import { ExecutionDataRecoveryService } from '../executionDataRecovery.service'; +import { ExecutionRecoveryService } from '../../executions/execution-recovery.service'; import { EventMessageAiNode, type EventMessageAiNodeOptions, @@ -68,7 +68,7 @@ export class MessageEventBus extends EventEmitter { private readonly eventDestinationsRepository: EventDestinationsRepository, private readonly workflowRepository: WorkflowRepository, private readonly orchestrationService: OrchestrationService, - private readonly recoveryService: ExecutionDataRecoveryService, + private readonly recoveryService: ExecutionRecoveryService, ) { super(); } @@ -185,10 +185,9 @@ export class MessageEventBus extends EventEmitter { ); await this.executionRepository.markAsCrashed([executionId]); } else { - await this.recoveryService.recoverExecutionData( + await this.recoveryService.recover( executionId, unsentAndUnfinished.unfinishedExecutions[executionId], - true, ); } } diff --git a/packages/cli/src/eventbus/executionDataRecovery.service.ts b/packages/cli/src/eventbus/executionDataRecovery.service.ts deleted file mode 100644 index 3ddee5edc71e4..0000000000000 --- a/packages/cli/src/eventbus/executionDataRecovery.service.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { Container, Service } from 'typedi'; -import type { DateTime } from 'luxon'; -import { Push } from '@/push'; -import { InternalHooks } from '@/InternalHooks'; -import type { IRun, IRunExecutionData, ITaskData } from 'n8n-workflow'; -import { NodeOperationError, WorkflowOperationError, sleep } from 'n8n-workflow'; - -import { ExecutionRepository } from '@db/repositories/execution.repository'; -import { getWorkflowHooksMain } from '@/WorkflowExecuteAdditionalData'; -import type { EventMessageTypes, EventNamesTypes } from './EventMessageClasses'; - -@Service() -export class ExecutionDataRecoveryService { - constructor( - private readonly push: Push, - private readonly executionRepository: ExecutionRepository, - ) {} - - // eslint-disable-next-line complexity - async recoverExecutionData( - executionId: string, - messages: EventMessageTypes[], - applyToDb: boolean, - ): Promise { - const executionEntry = await this.executionRepository.findSingleExecution(executionId, { - includeData: true, - unflattenData: true, - }); - - if (executionEntry && messages) { - let executionData = executionEntry.data; - let workflowError: WorkflowOperationError | undefined; - if (!executionData) { - executionData = { resultData: { runData: {} } }; - } - let nodeNames: string[] = []; - if ( - executionData?.resultData?.runData && - Object.keys(executionData.resultData.runData).length > 0 - ) { - } else { - if (!executionData.resultData) { - executionData.resultData = { - runData: {}, - }; - } else { - if (!executionData.resultData.runData) { - executionData.resultData.runData = {}; - } - } - } - nodeNames = executionEntry.workflowData.nodes.map((n) => n.name); - - let lastNodeRunTimestamp: DateTime | undefined = undefined; - - for (const nodeName of nodeNames) { - const nodeByName = executionEntry?.workflowData.nodes.find((n) => n.name === nodeName); - - if (!nodeByName) continue; - - const nodeStartedMessage = messages.find( - (message) => - message.eventName === 'n8n.node.started' && message.payload.nodeName === nodeName, - ); - const nodeFinishedMessage = messages.find( - (message) => - message.eventName === 'n8n.node.finished' && message.payload.nodeName === nodeName, - ); - - const executionTime = - nodeStartedMessage && nodeFinishedMessage - ? nodeFinishedMessage.ts.diff(nodeStartedMessage.ts).toMillis() - : 0; - - let taskData: ITaskData; - if (executionData.resultData.runData[nodeName]?.length > 0) { - taskData = executionData.resultData.runData[nodeName][0]; - } else { - taskData = { - startTime: nodeStartedMessage ? nodeStartedMessage.ts.toUnixInteger() : 0, - executionTime, - source: [null], - executionStatus: 'unknown', - }; - } - - if (nodeStartedMessage && !nodeFinishedMessage) { - const nodeError = new NodeOperationError( - nodeByName, - 'Node crashed, possible out-of-memory issue', - { - message: 'Execution stopped at this node', - description: - "n8n may have run out of memory while executing it. More context and tips on how to avoid this in the docs", - }, - ); - workflowError = new WorkflowOperationError( - 'Workflow did not finish, possible out-of-memory issue', - ); - taskData.error = nodeError; - taskData.executionStatus = 'crashed'; - executionData.resultData.lastNodeExecuted = nodeName; - if (nodeStartedMessage) lastNodeRunTimestamp = nodeStartedMessage.ts; - } else if (nodeStartedMessage && nodeFinishedMessage) { - taskData.executionStatus = 'success'; - if (taskData.data === undefined) { - taskData.data = { - main: [ - [ - { - json: { - isArtificialRecoveredEventItem: true, - }, - pairedItem: undefined, - }, - ], - ], - }; - } - } - - if (!executionData.resultData.runData[nodeName]) { - executionData.resultData.runData[nodeName] = [taskData]; - } - } - - if (!lastNodeRunTimestamp) { - const workflowEndedMessage = messages.find((message) => - ( - [ - 'n8n.workflow.success', - 'n8n.workflow.crashed', - 'n8n.workflow.failed', - ] as EventNamesTypes[] - ).includes(message.eventName), - ); - if (workflowEndedMessage) { - lastNodeRunTimestamp = workflowEndedMessage.ts; - } else { - if (!workflowError) { - workflowError = new WorkflowOperationError( - 'Workflow did not finish, possible out-of-memory issue', - ); - } - const workflowStartedMessage = messages.find( - (message) => message.eventName === 'n8n.workflow.started', - ); - if (workflowStartedMessage) { - lastNodeRunTimestamp = workflowStartedMessage.ts; - } - } - } - - if (!executionData.resultData.error && workflowError) { - executionData.resultData.error = workflowError; - } - - if (applyToDb) { - const newStatus = executionEntry.status === 'error' ? 'error' : 'crashed'; - await this.executionRepository.updateExistingExecution(executionId, { - data: executionData, - status: newStatus, - stoppedAt: lastNodeRunTimestamp?.toJSDate(), - }); - await Container.get(InternalHooks).onWorkflowPostExecute( - executionId, - executionEntry.workflowData, - { - data: executionData, - finished: false, - mode: executionEntry.mode, - waitTill: executionEntry.waitTill ?? undefined, - startedAt: executionEntry.startedAt, - stoppedAt: lastNodeRunTimestamp?.toJSDate(), - status: newStatus, - }, - ); - const iRunData: IRun = { - data: executionData, - finished: false, - mode: executionEntry.mode, - waitTill: executionEntry.waitTill ?? undefined, - startedAt: executionEntry.startedAt, - stoppedAt: lastNodeRunTimestamp?.toJSDate(), - status: newStatus, - }; - const workflowHooks = getWorkflowHooksMain( - { - userId: '', - workflowData: executionEntry.workflowData, - executionMode: executionEntry.mode, - executionData, - runData: executionData.resultData.runData, - retryOf: executionEntry.retryOf, - }, - executionId, - ); - - // execute workflowExecuteAfter hook to trigger error workflow - await workflowHooks.executeHookFunctions('workflowExecuteAfter', [iRunData]); - - // wait for UI to be back up and send the execution data - this.push.once('editorUiConnected', async () => { - // add a small timeout to make sure the UI is back up - await sleep(1000); - this.push.broadcast('executionRecovered', { executionId }); - }); - } - return executionData; - } - return; - } -} diff --git a/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts b/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts new file mode 100644 index 0000000000000..c9d75ee6a3243 --- /dev/null +++ b/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts @@ -0,0 +1,420 @@ +import Container from 'typedi'; +import { stringify } from 'flatted'; + +import { mockInstance } from '@test/mocking'; +import { randomInteger } from '@test-integration/random'; +import { createWorkflow } from '@test-integration/db/workflows'; +import { createExecution } from '@test-integration/db/executions'; +import * as testDb from '@test-integration/testDb'; + +import { ExecutionRecoveryService } from '@/executions/execution-recovery.service'; +import { ExecutionRepository } from '@/databases/repositories/execution.repository'; + +import { InternalHooks } from '@/InternalHooks'; +import { Push } from '@/push'; +import { ARTIFICIAL_TASK_DATA } from '@/constants'; +import { NodeCrashedError } from '@/errors/node-crashed.error'; +import { WorkflowCrashedError } from '@/errors/workflow-crashed.error'; +import { EventMessageNode } from '@/eventbus/EventMessageClasses/EventMessageNode'; +import { EventMessageWorkflow } from '@/eventbus/EventMessageClasses/EventMessageWorkflow'; +import type { EventMessageTypes as EventMessage } from '@/eventbus/EventMessageClasses'; +import type { WorkflowEntity } from '@/databases/entities/WorkflowEntity'; +import { NodeConnectionType } from 'n8n-workflow'; + +/** + * Workflow producing an execution whose data will be truncated by an instance crash. + */ +export const OOM_WORKFLOW: Partial = { + nodes: [ + { + parameters: {}, + id: '48ce17fe-9651-42ae-910c-48602a00f0bb', + name: 'When clicking "Test workflow"', + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [640, 260], + }, + { + parameters: { + category: 'oom', + memorySizeValue: 1000, + }, + id: '07a48151-96d3-45eb-961c-1daf85fbe052', + name: 'DebugHelper', + type: 'n8n-nodes-base.debugHelper', + typeVersion: 1, + position: [840, 260], + }, + ], + connections: { + 'When clicking "Test workflow"': { + main: [ + [ + { + node: 'DebugHelper', + type: NodeConnectionType.Main, + index: 0, + }, + ], + ], + }, + }, + pinData: {}, +}; + +/** + * Snapshot of an execution that will be truncated by an instance crash. + */ +export const IN_PROGRESS_EXECUTION_DATA = { + startData: {}, + resultData: { + runData: { + 'When clicking "Test workflow"': [ + { + hints: [], + startTime: 1716138610153, + executionTime: 1, + source: [], + executionStatus: 'success', + data: { + main: [ + [ + { + json: {}, + pairedItem: { + item: 0, + }, + }, + ], + ], + }, + }, + ], + }, + lastNodeExecuted: 'When clicking "Test workflow"', + }, + executionData: { + contextData: {}, + nodeExecutionStack: [ + { + node: { + parameters: { + category: 'oom', + memorySizeValue: 1000, + }, + id: '07a48151-96d3-45eb-961c-1daf85fbe052', + name: 'DebugHelper', + type: 'n8n-nodes-base.debugHelper', + typeVersion: 1, + position: [840, 260], + }, + data: { + main: [ + [ + { + json: {}, + pairedItem: { + item: 0, + }, + }, + ], + ], + }, + source: { + main: [ + { + previousNode: 'When clicking "Test workflow"', + }, + ], + }, + }, + ], + metadata: {}, + waitingExecution: {}, + waitingExecutionSource: {}, + }, +}; + +export const setupMessages = (executionId: string, workflowName: string): EventMessage[] => { + return [ + new EventMessageWorkflow({ + eventName: 'n8n.workflow.started', + payload: { executionId }, + }), + new EventMessageNode({ + eventName: 'n8n.node.started', + payload: { + executionId, + workflowName, + nodeName: 'When clicking "Test workflow"', + nodeType: 'n8n-nodes-base.manualTrigger', + }, + }), + new EventMessageNode({ + eventName: 'n8n.node.finished', + payload: { + executionId, + workflowName, + nodeName: 'When clicking "Test workflow"', + nodeType: 'n8n-nodes-base.manualTrigger', + }, + }), + new EventMessageNode({ + eventName: 'n8n.node.started', + payload: { + executionId, + workflowName, + nodeName: 'DebugHelper', + nodeType: 'n8n-nodes-base.debugHelper', + }, + }), + ]; +}; + +describe('ExecutionRecoveryService', () => { + let executionRecoveryService: ExecutionRecoveryService; + let push: Push; + + beforeAll(async () => { + await testDb.init(); + + mockInstance(InternalHooks); + push = mockInstance(Push); + + executionRecoveryService = new ExecutionRecoveryService( + push, + Container.get(ExecutionRepository), + ); + }); + + afterEach(async () => { + await testDb.truncate(['Execution', 'ExecutionData', 'Workflow']); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + describe('recover', () => { + it('should amend, persist, run hooks, broadcast', async () => { + /** + * Arrange + */ + // @ts-expect-error Private method + const amendSpy = jest.spyOn(executionRecoveryService, 'amend'); + const executionRepository = Container.get(ExecutionRepository); + const dbUpdateSpy = jest.spyOn(executionRepository, 'update'); + // @ts-expect-error Private method + const runHooksSpy = jest.spyOn(executionRecoveryService, 'runHooks'); + + const workflow = await createWorkflow(OOM_WORKFLOW); + + const execution = await createExecution( + { + status: 'running', + data: stringify(IN_PROGRESS_EXECUTION_DATA), + }, + workflow, + ); + + const messages = setupMessages(execution.id, workflow.name); + + /** + * Act + */ + + await executionRecoveryService.recover(execution.id, messages); + + /** + * Assert + */ + + expect(amendSpy).toHaveBeenCalledTimes(1); + expect(amendSpy).toHaveBeenCalledWith(execution.id, messages); + expect(dbUpdateSpy).toHaveBeenCalledTimes(1); + expect(runHooksSpy).toHaveBeenCalledTimes(1); + expect(push.once).toHaveBeenCalledTimes(1); + }); + + test('should amend a truncated execution where last node did not finish', async () => { + /** + * Arrange + */ + + const workflow = await createWorkflow(OOM_WORKFLOW); + + const execution = await createExecution( + { + status: 'running', + data: stringify(IN_PROGRESS_EXECUTION_DATA), + }, + workflow, + ); + + const messages = setupMessages(execution.id, workflow.name); + + /** + * Act + */ + + const amendedExecution = await executionRecoveryService.recover(execution.id, messages); + + /** + * Assert + */ + + const startOfLastNodeRun = messages + .find((m) => m.eventName === 'n8n.node.started' && m.payload.nodeName === 'DebugHelper') + ?.ts.toJSDate(); + + expect(amendedExecution).toEqual( + expect.objectContaining({ + status: 'crashed', + stoppedAt: startOfLastNodeRun, + }), + ); + + const resultData = amendedExecution?.data.resultData; + + if (!resultData) fail('Expected `resultData` to be defined'); + + expect(resultData.error).toBeInstanceOf(WorkflowCrashedError); + expect(resultData.lastNodeExecuted).toBe('DebugHelper'); + + const runData = resultData.runData; + + if (!runData) fail('Expected `runData` to be defined'); + + const manualTriggerTaskData = runData['When clicking "Test workflow"'].at(0); + const debugHelperTaskData = runData.DebugHelper.at(0); + + expect(manualTriggerTaskData?.executionStatus).toBe('success'); + expect(manualTriggerTaskData?.error).toBeUndefined(); + expect(manualTriggerTaskData?.startTime).not.toBe(ARTIFICIAL_TASK_DATA); + + expect(debugHelperTaskData?.executionStatus).toBe('crashed'); + expect(debugHelperTaskData?.error).toBeInstanceOf(NodeCrashedError); + }); + + test('should amend a truncated execution where last node finished', async () => { + /** + * Arrange + */ + + const workflow = await createWorkflow(OOM_WORKFLOW); + + const execution = await createExecution( + { + status: 'running', + data: stringify(IN_PROGRESS_EXECUTION_DATA), + }, + workflow, + ); + + const messages = setupMessages(execution.id, workflow.name); + messages.push( + new EventMessageNode({ + eventName: 'n8n.node.finished', + payload: { + executionId: execution.id, + workflowName: workflow.name, + nodeName: 'DebugHelper', + nodeType: 'n8n-nodes-base.debugHelper', + }, + }), + ); + + /** + * Act + */ + + const amendedExecution = await executionRecoveryService.recover(execution.id, messages); + + /** + * Assert + */ + + const endOfLastNoderun = messages + .find((m) => m.eventName === 'n8n.node.finished' && m.payload.nodeName === 'DebugHelper') + ?.ts.toJSDate(); + + expect(amendedExecution).toEqual( + expect.objectContaining({ + status: 'crashed', + stoppedAt: endOfLastNoderun, + }), + ); + + const resultData = amendedExecution?.data.resultData; + + if (!resultData) fail('Expected `resultData` to be defined'); + + expect(resultData.error).toBeUndefined(); + expect(resultData.lastNodeExecuted).toBe('DebugHelper'); + + const runData = resultData.runData; + + if (!runData) fail('Expected `runData` to be defined'); + + const manualTriggerTaskData = runData['When clicking "Test workflow"'].at(0); + const debugHelperTaskData = runData.DebugHelper.at(0); + + expect(manualTriggerTaskData?.executionStatus).toBe('success'); + expect(manualTriggerTaskData?.error).toBeUndefined(); + + expect(debugHelperTaskData?.executionStatus).toBe('success'); + expect(debugHelperTaskData?.error).toBeUndefined(); + expect(debugHelperTaskData?.data).toEqual(ARTIFICIAL_TASK_DATA); + }); + + test('should return `null` if no messages', async () => { + /** + * Arrange + */ + const workflow = await createWorkflow(OOM_WORKFLOW); + const execution = await createExecution( + { + status: 'running', + data: stringify(IN_PROGRESS_EXECUTION_DATA), + }, + workflow, + ); + const noMessages: EventMessage[] = []; + + /** + * Act + */ + + const amendedExecution = await executionRecoveryService.recover(execution.id, noMessages); + + /** + * Assert + */ + + expect(amendedExecution).toBeNull(); + }); + + test('should return `null` if no execution', async () => { + /** + * Arrange + */ + const inexistentExecutionId = randomInteger(100).toString(); + const messages = setupMessages(inexistentExecutionId, 'Some workflow'); + + /** + * Act + */ + + const amendedExecution = await executionRecoveryService.recover( + inexistentExecutionId, + messages, + ); + + /** + * Assert + */ + + expect(amendedExecution).toBeNull(); + }); + }); +}); diff --git a/packages/cli/src/executions/execution-recovery.service.ts b/packages/cli/src/executions/execution-recovery.service.ts new file mode 100644 index 0000000000000..1a57030561bb3 --- /dev/null +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -0,0 +1,189 @@ +import Container, { Service } from 'typedi'; +import { Push } from '@/push'; +import { sleep } from 'n8n-workflow'; +import { ExecutionRepository } from '@db/repositories/execution.repository'; +import { getWorkflowHooksMain } from '@/WorkflowExecuteAdditionalData'; // @TODO: Dependency cycle +import { InternalHooks } from '@/InternalHooks'; // @TODO: Dependency cycle if injected +import type { DateTime } from 'luxon'; +import type { IRun, ITaskData } from 'n8n-workflow'; +import type { EventMessageTypes } from '../eventbus/EventMessageClasses'; +import type { IExecutionResponse } from '@/Interfaces'; +import { NodeCrashedError } from '@/errors/node-crashed.error'; +import { WorkflowCrashedError } from '@/errors/workflow-crashed.error'; +import { ARTIFICIAL_TASK_DATA } from '@/constants'; + +/** + * Service for recovering executions truncated by an instance crash. + */ +@Service() +export class ExecutionRecoveryService { + constructor( + private readonly push: Push, + private readonly executionRepository: ExecutionRepository, + ) {} + + /** + * "Recovery" means (1) amending key properties of a truncated execution, + * (2) running post-execution hooks, and (3) returning the amended execution + * so the UI can reflect the error. "Recovery" does **not** mean injecting + * execution data from the logs (they hold none), or resuming the execution + * from the point of truncation, or re-running the whole execution. + * + * Recovery is only possible if event logs are available in the container. + * In regular mode, logs should but might not be available, e.g. due to container + * being recycled, max log size causing rotation, etc. In queue mode, as workers + * log to their own filesystems, only manual exections can be recovered. + */ + async recover(executionId: string, messages: EventMessageTypes[]) { + if (messages.length === 0) return null; + + const amendedExecution = await this.amend(executionId, messages); + + if (!amendedExecution) return null; + + await this.executionRepository.updateExistingExecution(executionId, amendedExecution); + + await this.runHooks(amendedExecution); + + this.push.once('editorUiConnected', async () => { + await sleep(1000); + this.push.broadcast('executionRecovered', { executionId }); + }); + + return amendedExecution; + } + + /** + * Amend `status`, `stoppedAt`, and `data` of an execution using event log messages. + */ + private async amend(executionId: string, messages: EventMessageTypes[]) { + const { nodeMessages, workflowMessages } = this.toRelevantMessages(messages); + + if (nodeMessages.length === 0) return null; + + const execution = await this.executionRepository.findSingleExecution(executionId, { + includeData: true, + unflattenData: true, + }); + + if (!execution) return null; + + const runExecutionData = execution.data ?? { resultData: { runData: {} } }; + + let lastNodeRunTimestamp: DateTime | undefined; + + for (const node of execution.workflowData.nodes) { + const nodeStartedMessage = nodeMessages.find( + (m) => m.payload.nodeName === node.name && m.eventName === 'n8n.node.started', + ); + + if (!nodeStartedMessage) continue; + + const nodeFinishedMessage = nodeMessages.find( + (m) => m.payload.nodeName === node.name && m.eventName === 'n8n.node.finished', + ); + + const taskData: ITaskData = { + startTime: nodeStartedMessage.ts.toUnixInteger(), + executionTime: -1, + source: [null], + }; + + if (nodeFinishedMessage) { + taskData.executionStatus = 'success'; + taskData.data ??= ARTIFICIAL_TASK_DATA; + taskData.executionTime = nodeFinishedMessage.ts.diff(nodeStartedMessage.ts).toMillis(); + lastNodeRunTimestamp = nodeFinishedMessage.ts; + } else { + taskData.executionStatus = 'crashed'; + taskData.error = new NodeCrashedError(node); + taskData.executionTime = 0; + runExecutionData.resultData.error = new WorkflowCrashedError(); + lastNodeRunTimestamp = nodeStartedMessage.ts; + } + + runExecutionData.resultData.lastNodeExecuted = node.name; + runExecutionData.resultData.runData[node.name] = [taskData]; + } + + return { + ...execution, + status: execution.status === 'error' ? 'error' : 'crashed', + stoppedAt: this.toStoppedAt(lastNodeRunTimestamp, workflowMessages), + data: runExecutionData, + } as IExecutionResponse; + } + + // ---------------------------------- + // private + // ---------------------------------- + + private toRelevantMessages(messages: EventMessageTypes[]) { + return messages.reduce<{ + nodeMessages: EventMessageTypes[]; + workflowMessages: EventMessageTypes[]; + }>( + (acc, cur) => { + if (cur.eventName.startsWith('n8n.node.')) { + acc.nodeMessages.push(cur); + } else if (cur.eventName.startsWith('n8n.workflow.')) { + acc.workflowMessages.push(cur); + } + + return acc; + }, + { nodeMessages: [], workflowMessages: [] }, + ); + } + + private toStoppedAt(timestamp: DateTime | undefined, messages: EventMessageTypes[]) { + if (timestamp) return timestamp.toJSDate(); + + const WORKFLOW_END_EVENTS = new Set([ + 'n8n.workflow.success', + 'n8n.workflow.crashed', + 'n8n.workflow.failed', + ]); + + return ( + messages.find((m) => WORKFLOW_END_EVENTS.has(m.eventName)) ?? + messages.find((m) => m.eventName === 'n8n.workflow.started') + )?.ts.toJSDate(); + } + + private async runHooks(execution: IExecutionResponse) { + await Container.get(InternalHooks).onWorkflowPostExecute(execution.id, execution.workflowData, { + data: execution.data, + finished: false, + mode: execution.mode, + waitTill: execution.waitTill, + startedAt: execution.startedAt, + stoppedAt: execution.stoppedAt, + status: execution.status, + }); + + const externalHooks = getWorkflowHooksMain( + { + userId: '', + workflowData: execution.workflowData, + executionMode: execution.mode, + executionData: execution.data, + runData: execution.data.resultData.runData, + retryOf: execution.retryOf, + }, + execution.id, + ); + + const run: IRun = { + data: execution.data, + finished: false, + mode: execution.mode, + waitTill: execution.waitTill ?? undefined, + startedAt: execution.startedAt, + stoppedAt: execution.stoppedAt, + status: execution.status, + }; + + await externalHooks.executeHookFunctions('workflowExecuteAfter', [run]); + } +} diff --git a/packages/cli/test/integration/eventbus.ee.test.ts b/packages/cli/test/integration/eventbus.ee.test.ts index 0c8627ac48a5c..b19f024f52080 100644 --- a/packages/cli/test/integration/eventbus.ee.test.ts +++ b/packages/cli/test/integration/eventbus.ee.test.ts @@ -22,7 +22,7 @@ import type { MessageEventBusDestinationWebhook } from '@/eventbus/MessageEventB import type { MessageEventBusDestinationSentry } from '@/eventbus/MessageEventBusDestination/MessageEventBusDestinationSentry.ee'; import { EventMessageAudit } from '@/eventbus/EventMessageClasses/EventMessageAudit'; import type { EventNamesTypes } from '@/eventbus/EventMessageClasses'; -import { ExecutionDataRecoveryService } from '@/eventbus/executionDataRecovery.service'; +import { ExecutionRecoveryService } from '@/executions/execution-recovery.service'; import * as utils from './shared/utils'; import { createUser } from './shared/db/users'; @@ -80,7 +80,7 @@ async function confirmIdSent(id: string) { expect(sent.find((msg) => msg.id === id)).toBeTruthy(); } -mockInstance(ExecutionDataRecoveryService); +mockInstance(ExecutionRecoveryService); const testServer = utils.setupTestServer({ endpointGroups: ['eventBus'], enabledFeatures: ['feat:logStreaming'], diff --git a/packages/cli/test/integration/eventbus.test.ts b/packages/cli/test/integration/eventbus.test.ts index 1acb8ffa36667..e4e58f96fa330 100644 --- a/packages/cli/test/integration/eventbus.test.ts +++ b/packages/cli/test/integration/eventbus.test.ts @@ -1,6 +1,6 @@ import type { User } from '@db/entities/User'; import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; -import { ExecutionDataRecoveryService } from '@/eventbus/executionDataRecovery.service'; +import { ExecutionRecoveryService } from '@/executions/execution-recovery.service'; import * as utils from './shared/utils/'; import { createUser } from './shared/db/users'; @@ -17,7 +17,7 @@ let owner: User; let authOwnerAgent: SuperAgentTest; mockInstance(MessageEventBus); -mockInstance(ExecutionDataRecoveryService); +mockInstance(ExecutionRecoveryService); const testServer = utils.setupTestServer({ endpointGroups: ['eventBus'], enabledFeatures: [], // do not enable logstreaming diff --git a/packages/cli/test/integration/metrics.test.ts b/packages/cli/test/integration/metrics.test.ts index 394ec026c4dd5..0e62ddbae0832 100644 --- a/packages/cli/test/integration/metrics.test.ts +++ b/packages/cli/test/integration/metrics.test.ts @@ -5,12 +5,12 @@ import request from 'supertest'; import config from '@/config'; import { N8N_VERSION } from '@/constants'; import { MetricsService } from '@/services/metrics.service'; -import { ExecutionDataRecoveryService } from '@/eventbus/executionDataRecovery.service'; +import { ExecutionRecoveryService } from '@/executions/execution-recovery.service'; import { setupTestServer } from './shared/utils'; import { mockInstance } from '../shared/mocking'; -mockInstance(ExecutionDataRecoveryService); +mockInstance(ExecutionRecoveryService); jest.unmock('@/eventbus/MessageEventBus/MessageEventBus'); config.set('endpoints.metrics.enable', true); config.set('endpoints.metrics.includeDefaultMetrics', false); From 67932c0b76df7ece343eb3689569dfe36ae61f0d Mon Sep 17 00:00:00 2001 From: Valentin Coppin Date: Fri, 7 Jun 2024 16:30:26 +0200 Subject: [PATCH 04/20] fix: Fix typo with submitted (no-changelog) (#9662) --- packages/cli/templates/form-trigger.handlebars | 2 +- packages/nodes-base/nodes/Wait/Wait.node.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index 8f65263eec2de..a54168d2af0c2 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -345,7 +345,7 @@ + + + 22 + + @@ -159,9 +179,14 @@ exports[`RunDataJsonSchema.vue > renders schema for data 1`] = ` - hobbies + + + + hobbies + + @@ -228,20 +253,37 @@ exports[`RunDataJsonSchema.vue > renders schema for data 1`] = ` fill="currentColor" /> - - hobbies + + + + + hobbies + + - [0] + + + + [0] + + + + + surfing + + @@ -280,20 +322,37 @@ exports[`RunDataJsonSchema.vue > renders schema for data 1`] = ` fill="currentColor" /> - - hobbies + + + + + hobbies + + - [1] + + + + [1] + + + + + traveling + + @@ -417,9 +476,14 @@ exports[`RunDataJsonSchema.vue > renders schema with spaces and dots 1`] = ` - hello world + + + + hello world + + @@ -486,13 +550,25 @@ exports[`RunDataJsonSchema.vue > renders schema with spaces and dots 1`] = ` fill="currentColor" /> - - hello world + + + + + hello world + + - [0] + + + + [0] + + @@ -561,9 +637,14 @@ exports[`RunDataJsonSchema.vue > renders schema with spaces and dots 1`] = ` - test + + + + test + + @@ -632,16 +713,26 @@ exports[`RunDataJsonSchema.vue > renders schema with spaces and dots 1`] = ` - more to think about + + + + more to think about + + + + + 1 + + @@ -685,16 +776,26 @@ exports[`RunDataJsonSchema.vue > renders schema with spaces and dots 1`] = ` - test.how + + + + test.how + + + + + ignore + + From af3ac2db2850237663cf2f24a4466ae4a0fc6f16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 10 Jun 2024 15:49:50 +0200 Subject: [PATCH 09/20] refactor: Set up Cypress as pnpm workspace (no-changelog) (#6049) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ --- .github/workflows/e2e-reusable.yml | 8 +- .gitignore | 3 - cypress/.eslintrc.js | 24 ++++++ cypress/.gitignore | 3 + cypress/augmentation.d.ts | 4 + .../composables/becomeTemplateCreatorCta.ts | 2 +- .../composables/modals/credential-modal.ts | 2 +- cypress/composables/ndv.ts | 4 +- .../setup-workflow-credentials-button.ts | 2 +- cypress/composables/workflow.ts | 2 +- .../cypress.config.js | 6 ++ cypress/e2e/1-workflows.cy.ts | 2 +- cypress/e2e/10-undo-redo.cy.ts | 16 ++-- cypress/e2e/11-inline-expression-editor.cy.ts | 2 +- cypress/e2e/12-canvas-actions.cy.ts | 2 +- cypress/e2e/12-canvas.cy.ts | 6 +- cypress/e2e/13-pinning.cy.ts | 9 +-- .../14-data-transformation-expressions.cy.ts | 2 +- cypress/e2e/14-mapping.cy.ts | 6 +- cypress/e2e/15-scheduler-node.cy.ts | 45 +++++++----- cypress/e2e/16-webhook-node.cy.ts | 44 ++++++----- cypress/e2e/18-user-management.cy.ts | 14 ++-- cypress/e2e/19-execution.cy.ts | 27 +++---- cypress/e2e/2-credentials.cy.ts | 3 +- cypress/e2e/20-workflow-executions.cy.ts | 9 ++- cypress/e2e/21-community-nodes.cy.ts | 5 +- ...06-ADO-pinned-data-execution-preview.cy.ts | 6 +- .../2230-ADO-ndv-reset-data-pagination.cy.ts | 2 +- cypress/e2e/24-ndv-paired-item.cy.ts | 2 +- cypress/e2e/25-stickies.cy.ts | 13 +--- .../e2e/27-two-factor-authentication.cy.ts | 10 +-- .../e2e/30-editor-after-route-changes.cy.ts | 2 +- cypress/e2e/30-langchain.cy.ts | 34 ++++----- cypress/e2e/32-node-io-filter.cy.ts | 2 +- cypress/e2e/33-settings-personal.cy.ts | 4 +- .../e2e/34-template-credentials-setup.cy.ts | 4 +- cypress/e2e/39-import-workflow.cy.ts | 2 +- cypress/e2e/39-projects.cy.ts | 59 ++++++++++++--- cypress/e2e/41-editors.cy.ts | 13 +++- cypress/e2e/5-ndv.cy.ts | 14 ++-- cypress/e2e/7-workflow-actions.cy.ts | 1 - cypress/e2e/9-expression-editor-modal.cy.ts | 2 +- cypress/package.json | 28 +++++++ cypress/pages/bannerStack.ts | 1 + cypress/pages/base.ts | 7 +- cypress/pages/credentials.ts | 2 + cypress/pages/demo.ts | 7 +- cypress/pages/features/node-creator.ts | 29 +------- cypress/pages/mfa-login.ts | 1 + cypress/pages/modals/credentials-modal.ts | 3 +- cypress/pages/modals/message-box.ts | 1 + .../pages/modals/workflow-sharing-modal.ts | 1 + cypress/pages/ndv.ts | 17 ++--- cypress/pages/settings-log-streaming.ts | 4 +- cypress/pages/settings-personal.ts | 4 +- cypress/pages/settings-usage.ts | 2 + cypress/pages/settings-users.ts | 2 + cypress/pages/settings.ts | 2 + cypress/pages/sidebar/main-sidebar.ts | 3 +- cypress/pages/sidebar/settings-sidebar.ts | 1 + cypress/pages/signin.ts | 1 + cypress/pages/template-credential-setup.ts | 2 +- cypress/pages/template-workflow.ts | 21 +++--- cypress/pages/variables.ts | 3 +- cypress/pages/workerView.ts | 1 + cypress/pages/workflow-executions-tab.ts | 1 + cypress/pages/workflow-history.ts | 8 +- cypress/pages/workflow.ts | 33 ++++----- cypress/pages/workflows.ts | 1 + {scripts => cypress/scripts}/run-e2e.js | 0 cypress/support/commands.ts | 8 +- cypress/support/e2e.ts | 4 +- cypress/support/index.ts | 11 ++- cypress/tsconfig.json | 3 +- cypress/types.ts | 18 ++++- cypress/utils/executions.ts | 11 ++- package.json | 13 +--- pnpm-lock.yaml | 73 ++++++++++--------- pnpm-workspace.yaml | 1 + 79 files changed, 435 insertions(+), 315 deletions(-) create mode 100644 cypress/.eslintrc.js create mode 100644 cypress/.gitignore create mode 100644 cypress/augmentation.d.ts rename cypress.config.js => cypress/cypress.config.js (76%) create mode 100644 cypress/package.json rename {scripts => cypress/scripts}/run-e2e.js (100%) diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml index b5cc8a38c60aa..771dfab35a9be 100644 --- a/.github/workflows/e2e-reusable.yml +++ b/.github/workflows/e2e-reusable.yml @@ -87,7 +87,7 @@ jobs: git fetch origin pull/${{ inputs.pr_number }}/head git checkout FETCH_HEAD - - uses: pnpm/action-setup@v2.4.0 + - uses: pnpm/action-setup@v4.0.0 - name: Install dependencies run: pnpm install --frozen-lockfile @@ -103,6 +103,7 @@ jobs: VUE_APP_MAX_PINNED_DATA_SIZE: 16384 - name: Cypress install + working-directory: cypress run: pnpm cypress:install - name: Cache build artifacts @@ -138,7 +139,7 @@ jobs: git fetch origin pull/${{ inputs.pr_number }}/head git checkout FETCH_HEAD - - uses: pnpm/action-setup@v2.4.0 + - uses: pnpm/action-setup@v4.0.0 - name: Restore cached pnpm modules uses: actions/cache/restore@v4.0.0 @@ -155,6 +156,7 @@ jobs: - name: Cypress run uses: cypress-io/github-action@v6.6.1 with: + working-directory: cypress install: false start: pnpm start wait-on: 'http://localhost:5678' @@ -165,7 +167,7 @@ jobs: # in the same parent workflow ci-build-id: ${{ needs.prepare.outputs.uuid }} spec: '/__w/n8n/n8n/cypress/${{ inputs.spec }}' - config-file: /__w/n8n/n8n/cypress.config.js + config-file: /__w/n8n/n8n/cypress/cypress.config.js env: NODE_OPTIONS: --dns-result-order=ipv4first CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} diff --git a/.gitignore b/.gitignore index 592a8894f3fad..e36cf95823196 100644 --- a/.gitignore +++ b/.gitignore @@ -18,9 +18,6 @@ nodelinter.config.json packages/**/.turbo .turbo *.tsbuildinfo -cypress/videos/* -cypress/screenshots/* -cypress/downloads/* *.swp CHANGELOG-*.md *.mdx diff --git a/cypress/.eslintrc.js b/cypress/.eslintrc.js new file mode 100644 index 0000000000000..dc6abe3a7125f --- /dev/null +++ b/cypress/.eslintrc.js @@ -0,0 +1,24 @@ +const sharedOptions = require('@n8n_io/eslint-config/shared'); + +/** + * @type {import('@types/eslint').ESLint.ConfigData} + */ +module.exports = { + extends: ['@n8n_io/eslint-config/base'], + + ...sharedOptions(__dirname), + + rules: { + // TODO: remove these rules + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-unused-expressions': 'off', + '@typescript-eslint/no-use-before-define': 'off', + '@typescript-eslint/promise-function-async': 'off', + 'n8n-local-rules/no-uncaught-json-parse': 'off', + }, +}; diff --git a/cypress/.gitignore b/cypress/.gitignore new file mode 100644 index 0000000000000..a1d14ddebb4f0 --- /dev/null +++ b/cypress/.gitignore @@ -0,0 +1,3 @@ +videos/ +screenshots/ +downloads/ diff --git a/cypress/augmentation.d.ts b/cypress/augmentation.d.ts new file mode 100644 index 0000000000000..334bc0e9f463d --- /dev/null +++ b/cypress/augmentation.d.ts @@ -0,0 +1,4 @@ +declare module 'cypress-otp' { + // eslint-disable-next-line import/no-default-export + export default function generateOTPToken(secret: string): string; +} diff --git a/cypress/composables/becomeTemplateCreatorCta.ts b/cypress/composables/becomeTemplateCreatorCta.ts index 55fc985c745bc..ca35e611d9214 100644 --- a/cypress/composables/becomeTemplateCreatorCta.ts +++ b/cypress/composables/becomeTemplateCreatorCta.ts @@ -10,7 +10,7 @@ export const getCloseBecomeTemplateCreatorCtaButton = () => //#region Actions export const interceptCtaRequestWithResponse = (becomeCreator: boolean) => { - return cy.intercept('GET', `/rest/cta/become-creator`, { + return cy.intercept('GET', '/rest/cta/become-creator', { body: becomeCreator, }); }; diff --git a/cypress/composables/modals/credential-modal.ts b/cypress/composables/modals/credential-modal.ts index bfcbf89251059..8ce6a86049bcc 100644 --- a/cypress/composables/modals/credential-modal.ts +++ b/cypress/composables/modals/credential-modal.ts @@ -42,7 +42,7 @@ export function closeCredentialModal() { getCredentialModalCloseButton().click(); } -export function setCredentialValues(values: Record, save = true) { +export function setCredentialValues(values: Record, save = true) { Object.entries(values).forEach(([key, value]) => { setCredentialConnectionParameterInputByName(key, value); }); diff --git a/cypress/composables/ndv.ts b/cypress/composables/ndv.ts index e2fc03d7afe61..c3fab73f8c8b6 100644 --- a/cypress/composables/ndv.ts +++ b/cypress/composables/ndv.ts @@ -2,7 +2,7 @@ * Getters */ -import { getVisibleSelect } from "../utils"; +import { getVisibleSelect } from '../utils'; export function getCredentialSelect(eq = 0) { return cy.getByTestId('node-credentials-select').eq(eq); @@ -75,7 +75,7 @@ export function setParameterInputByName(name: string, value: string) { } export function toggleParameterCheckboxInputByName(name: string) { - getParameterInputByName(name).find('input[type="checkbox"]').realClick() + getParameterInputByName(name).find('input[type="checkbox"]').realClick(); } export function setParameterSelectByContent(name: string, content: string) { diff --git a/cypress/composables/setup-workflow-credentials-button.ts b/cypress/composables/setup-workflow-credentials-button.ts index 6b1b9b69d4582..8285454d836ed 100644 --- a/cypress/composables/setup-workflow-credentials-button.ts +++ b/cypress/composables/setup-workflow-credentials-button.ts @@ -2,4 +2,4 @@ * Getters */ -export const getSetupWorkflowCredentialsButton = () => cy.get(`button:contains("Set up template")`); +export const getSetupWorkflowCredentialsButton = () => cy.get('button:contains("Set up template")'); diff --git a/cypress/composables/workflow.ts b/cypress/composables/workflow.ts index 1aa469b19458f..b3d6f20c2840c 100644 --- a/cypress/composables/workflow.ts +++ b/cypress/composables/workflow.ts @@ -51,7 +51,7 @@ export function getNodeByName(name: string) { export function disableNode(name: string) { const target = getNodeByName(name); target.rightclick(name ? 'center' : 'topLeft', { force: true }); - cy.getByTestId(`context-menu-item-toggle_activation`).click(); + cy.getByTestId('context-menu-item-toggle_activation').click(); } export function getConnectionBySourceAndTarget(source: string, target: string) { diff --git a/cypress.config.js b/cypress/cypress.config.js similarity index 76% rename from cypress.config.js rename to cypress/cypress.config.js index f01672c6f9b8b..c82f039994f26 100644 --- a/cypress.config.js +++ b/cypress/cypress.config.js @@ -18,6 +18,12 @@ module.exports = defineConfig({ screenshotOnRunFailure: true, experimentalInteractiveRunEvents: true, experimentalSessionAndOrigin: true, + specPattern: 'e2e/**/*.ts', + supportFile: 'support/e2e.ts', + fixturesFolder: 'fixtures', + downloadsFolder: 'downloads', + screenshotsFolder: 'screenshots', + videosFolder: 'videos', }, env: { MAX_PINNED_DATA_SIZE: process.env.VUE_APP_MAX_PINNED_DATA_SIZE diff --git a/cypress/e2e/1-workflows.cy.ts b/cypress/e2e/1-workflows.cy.ts index 25f4f3cb0a697..d14506d17e4d5 100644 --- a/cypress/e2e/1-workflows.cy.ts +++ b/cypress/e2e/1-workflows.cy.ts @@ -1,6 +1,6 @@ +import { v4 as uuid } from 'uuid'; import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; -import { v4 as uuid } from 'uuid'; const WorkflowsPage = new WorkflowsPageClass(); const WorkflowPage = new WorkflowPageClass(); diff --git a/cypress/e2e/10-undo-redo.cy.ts b/cypress/e2e/10-undo-redo.cy.ts index 3190987541673..19465ed74951f 100644 --- a/cypress/e2e/10-undo-redo.cy.ts +++ b/cypress/e2e/10-undo-redo.cy.ts @@ -1,5 +1,9 @@ -import { CODE_NODE_NAME, SET_NODE_NAME, EDIT_FIELDS_SET_NODE_NAME } from './../constants'; -import { SCHEDULE_TRIGGER_NODE_NAME } from '../constants'; +import { + SCHEDULE_TRIGGER_NODE_NAME, + CODE_NODE_NAME, + SET_NODE_NAME, + EDIT_FIELDS_SET_NODE_NAME, +} from '../constants'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; import { MessageBox as MessageBoxClass } from '../pages/modals/message-box'; import { NDV } from '../pages/ndv'; @@ -338,8 +342,8 @@ describe('Undo/Redo', () => { WorkflowPage.getters.nodeConnections().should('have.length', 1); cy.get(WorkflowPage.getters.getEndpointSelector('input', 'Switch')).should('have.length', 1); cy.get(WorkflowPage.getters.getEndpointSelector('input', 'Switch')) - .should('have.css', 'left', `637px`) - .should('have.css', 'top', `501px`); + .should('have.css', 'left', '637px') + .should('have.css', 'top', '501px'); cy.fixture('Test_workflow_form_switch.json').then((data) => { cy.get('body').paste(JSON.stringify(data)); @@ -353,8 +357,8 @@ describe('Undo/Redo', () => { WorkflowPage.getters.nodeConnections().should('have.length', 1); cy.get(WorkflowPage.getters.getEndpointSelector('input', 'Switch')).should('have.length', 1); cy.get(WorkflowPage.getters.getEndpointSelector('input', 'Switch')) - .should('have.css', 'left', `637px`) - .should('have.css', 'top', `501px`); + .should('have.css', 'left', '637px') + .should('have.css', 'top', '501px'); }); it('should not undo/redo when NDV or a modal is open', () => { diff --git a/cypress/e2e/11-inline-expression-editor.cy.ts b/cypress/e2e/11-inline-expression-editor.cy.ts index 45fb7752edf18..a7fadd196f32e 100644 --- a/cypress/e2e/11-inline-expression-editor.cy.ts +++ b/cypress/e2e/11-inline-expression-editor.cy.ts @@ -8,7 +8,7 @@ describe('Inline expression editor', () => { beforeEach(() => { WorkflowPage.actions.visit(); WorkflowPage.actions.addInitialNodeToCanvas('Schedule'); - cy.on('uncaught:exception', (err) => err.name !== 'ExpressionError'); + cy.on('uncaught:exception', (error) => error.name !== 'ExpressionError'); }); describe('Static data', () => { diff --git a/cypress/e2e/12-canvas-actions.cy.ts b/cypress/e2e/12-canvas-actions.cy.ts index 3c517b6c9840f..4cdfe6808e23a 100644 --- a/cypress/e2e/12-canvas-actions.cy.ts +++ b/cypress/e2e/12-canvas-actions.cy.ts @@ -1,3 +1,4 @@ +import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; import { MANUAL_TRIGGER_NODE_NAME, MANUAL_TRIGGER_NODE_DISPLAY_NAME, @@ -7,7 +8,6 @@ import { IF_NODE_NAME, HTTP_REQUEST_NODE_NAME, } from './../constants'; -import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; const WorkflowPage = new WorkflowPageClass(); describe('Canvas Actions', () => { diff --git a/cypress/e2e/12-canvas.cy.ts b/cypress/e2e/12-canvas.cy.ts index 13fef5b10ccce..db6b38d53a7b6 100644 --- a/cypress/e2e/12-canvas.cy.ts +++ b/cypress/e2e/12-canvas.cy.ts @@ -1,3 +1,5 @@ +import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; +import { NDV, WorkflowExecutionsTab } from '../pages'; import { MANUAL_TRIGGER_NODE_NAME, MANUAL_TRIGGER_NODE_DISPLAY_NAME, @@ -7,8 +9,6 @@ import { SWITCH_NODE_NAME, MERGE_NODE_NAME, } from './../constants'; -import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; -import { NDV, WorkflowExecutionsTab } from '../pages'; const WorkflowPage = new WorkflowPageClass(); const ExecutionsTab = new WorkflowExecutionsTab(); @@ -258,7 +258,7 @@ describe('Canvas Node Manipulation and Navigation', () => { WorkflowPage.actions.pinchToZoom(1, 'zoomOut'); // Zoom in 1x + Zoom out 1x should reset to default (=1) - WorkflowPage.getters.nodeView().should('have.css', 'transform', `matrix(1, 0, 0, 1, 0, 0)`); + WorkflowPage.getters.nodeView().should('have.css', 'transform', 'matrix(1, 0, 0, 1, 0, 0)'); WorkflowPage.actions.pinchToZoom(1, 'zoomOut'); WorkflowPage.getters diff --git a/cypress/e2e/13-pinning.cy.ts b/cypress/e2e/13-pinning.cy.ts index a27b830ce6a57..0929e8cfae8fd 100644 --- a/cypress/e2e/13-pinning.cy.ts +++ b/cypress/e2e/13-pinning.cy.ts @@ -136,7 +136,7 @@ describe('Data pinning', () => { ndv.actions.pastePinnedData([ { - test: '1'.repeat(Cypress.env('MAX_PINNED_DATA_SIZE')), + test: '1'.repeat(Cypress.env('MAX_PINNED_DATA_SIZE') as number), }, ]); workflowPage.getters @@ -151,10 +151,8 @@ describe('Data pinning', () => { ndv.getters.pinDataButton().should('not.exist'); ndv.getters.editPinnedDataButton().should('be.visible'); - ndv.actions.setPinnedData('[ { "name": "First item", "code": 2dsa }]') - workflowPage.getters - .errorToast() - .should('contain', 'Unable to save due to invalid JSON'); + ndv.actions.setPinnedData('[ { "name": "First item", "code": 2dsa }]'); + workflowPage.getters.errorToast().should('contain', 'Unable to save due to invalid JSON'); }); it('Should be able to reference paired items in a node located before pinned data', () => { @@ -168,6 +166,7 @@ describe('Data pinning', () => { ndv.actions.close(); workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true); + // eslint-disable-next-line @typescript-eslint/no-use-before-define setExpressionOnStringValueInSet(`{{ $('${HTTP_REQUEST_NODE_NAME}').item`); const output = '[Object: {"json": {"http": 123}, "pairedItem": {"item": 0}}]'; diff --git a/cypress/e2e/14-data-transformation-expressions.cy.ts b/cypress/e2e/14-data-transformation-expressions.cy.ts index 21c958d691fec..e823e31d5e0aa 100644 --- a/cypress/e2e/14-data-transformation-expressions.cy.ts +++ b/cypress/e2e/14-data-transformation-expressions.cy.ts @@ -1,5 +1,5 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ import { WorkflowPage, NDV } from '../pages'; -import { getVisibleSelect } from '../utils'; const wf = new WorkflowPage(); const ndv = new NDV(); diff --git a/cypress/e2e/14-mapping.cy.ts b/cypress/e2e/14-mapping.cy.ts index f8711db226e73..365efbd0d385e 100644 --- a/cypress/e2e/14-mapping.cy.ts +++ b/cypress/e2e/14-mapping.cy.ts @@ -1,10 +1,10 @@ +import { WorkflowPage, NDV } from '../pages'; +import { getVisibleSelect } from '../utils'; import { MANUAL_TRIGGER_NODE_NAME, MANUAL_TRIGGER_NODE_DISPLAY_NAME, SCHEDULE_TRIGGER_NODE_NAME, } from './../constants'; -import { WorkflowPage, NDV } from '../pages'; -import { getVisibleSelect } from '../utils'; const workflowPage = new WorkflowPage(); const ndv = new NDV(); @@ -170,7 +170,7 @@ describe('Data mapping', () => { }); it('maps expressions from previous nodes', () => { - cy.createFixtureWorkflow('Test_workflow_3.json', `My test workflow`); + cy.createFixtureWorkflow('Test_workflow_3.json', 'My test workflow'); workflowPage.actions.zoomToFit(); workflowPage.actions.openNode('Set1'); diff --git a/cypress/e2e/15-scheduler-node.cy.ts b/cypress/e2e/15-scheduler-node.cy.ts index 0021455619811..fecaef038a7d6 100644 --- a/cypress/e2e/15-scheduler-node.cy.ts +++ b/cypress/e2e/15-scheduler-node.cy.ts @@ -1,12 +1,13 @@ import { WorkflowPage, WorkflowsPage, NDV } from '../pages'; import { BACKEND_BASE_URL } from '../constants'; import { getVisibleSelect } from '../utils'; +import type { ExecutionResponse } from '../types'; const workflowsPage = new WorkflowsPage(); const workflowPage = new WorkflowPage(); const ndv = new NDV(); -describe('Schedule Trigger node', async () => { +describe('Schedule Trigger node', () => { beforeEach(() => { workflowPage.actions.visit(); }); @@ -37,30 +38,34 @@ describe('Schedule Trigger node', async () => { const workflowId = url.split('/').pop(); cy.wait(1200); - cy.request('GET', `${BACKEND_BASE_URL}/rest/executions`).then((response) => { - expect(response.status).to.eq(200); - expect(workflowId).to.not.be.undefined; - expect(response.body.data.results.length).to.be.greaterThan(0); - const matchingExecutions = response.body.data.results.filter( - (execution: any) => execution.workflowId === workflowId, - ); - expect(matchingExecutions).to.have.length(1); - - cy.wait(1200); - cy.request('GET', `${BACKEND_BASE_URL}/rest/executions`).then((response) => { + cy.request('GET', `${BACKEND_BASE_URL}/rest/executions`).then( + (response) => { expect(response.status).to.eq(200); + expect(workflowId).to.not.be.undefined; expect(response.body.data.results.length).to.be.greaterThan(0); const matchingExecutions = response.body.data.results.filter( - (execution: any) => execution.workflowId === workflowId, + (execution) => execution.workflowId === workflowId, ); - expect(matchingExecutions).to.have.length(2); + expect(matchingExecutions).to.have.length(1); + + cy.wait(1200); + cy.request('GET', `${BACKEND_BASE_URL}/rest/executions`).then( + (response1) => { + expect(response1.status).to.eq(200); + expect(response1.body.data.results.length).to.be.greaterThan(0); + const matchingExecutions1 = response1.body.data.results.filter( + (execution: any) => execution.workflowId === workflowId, + ); + expect(matchingExecutions1).to.have.length(2); - workflowPage.actions.activateWorkflow(); - workflowPage.getters.activatorSwitch().should('not.have.class', 'is-checked'); - cy.visit(workflowsPage.url); - workflowsPage.actions.deleteWorkFlow('Schedule Trigger Workflow'); - }); - }); + workflowPage.actions.activateWorkflow(); + workflowPage.getters.activatorSwitch().should('not.have.class', 'is-checked'); + cy.visit(workflowsPage.url); + workflowsPage.actions.deleteWorkFlow('Schedule Trigger Workflow'); + }, + ); + }, + ); }); }); }); diff --git a/cypress/e2e/16-webhook-node.cy.ts b/cypress/e2e/16-webhook-node.cy.ts index 560fc41056b30..c28046dc173ed 100644 --- a/cypress/e2e/16-webhook-node.cy.ts +++ b/cypress/e2e/16-webhook-node.cy.ts @@ -1,5 +1,5 @@ -import { WorkflowPage, NDV, CredentialsModal } from '../pages'; import { v4 as uuid } from 'uuid'; +import { WorkflowPage, NDV, CredentialsModal } from '../pages'; import { cowBase64 } from '../support/binaryTestFiles'; import { BACKEND_BASE_URL, EDIT_FIELDS_SET_NODE_NAME } from '../constants'; import { getVisibleSelect } from '../utils'; @@ -75,7 +75,7 @@ const simpleWebhookCall = (options: SimpleWebhookCallOptions) => { } }; -describe('Webhook Trigger node', async () => { +describe('Webhook Trigger node', () => { beforeEach(() => { workflowPage.actions.visit(); }); @@ -121,10 +121,12 @@ describe('Webhook Trigger node', async () => { workflowPage.actions.executeWorkflow(); cy.wait(waitForWebhook); - cy.request('GET', `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then((response) => { - expect(response.status).to.eq(200); - expect(response.body.MyValue).to.eq(1234); - }); + cy.request<{ MyValue: number }>('GET', `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then( + (response) => { + expect(response.status).to.eq(200); + expect(response.body.MyValue).to.eq(1234); + }, + ); }); it('should listen for a GET request and respond custom status code 201', () => { @@ -161,10 +163,12 @@ describe('Webhook Trigger node', async () => { workflowPage.actions.executeWorkflow(); cy.wait(waitForWebhook); - cy.request('GET', `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then((response) => { - expect(response.status).to.eq(200); - expect(response.body.MyValue).to.eq(1234); - }); + cy.request<{ MyValue: number }>('GET', `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then( + (response) => { + expect(response.status).to.eq(200); + expect(response.body.MyValue).to.eq(1234); + }, + ); }); it('should listen for a GET request and respond with last node binary data', () => { @@ -200,10 +204,12 @@ describe('Webhook Trigger node', async () => { workflowPage.actions.executeWorkflow(); cy.wait(waitForWebhook); - cy.request('GET', `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then((response) => { - expect(response.status).to.eq(200); - expect(Object.keys(response.body).includes('data')).to.be.true; - }); + cy.request<{ data: unknown }>('GET', `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then( + (response) => { + expect(response.status).to.eq(200); + expect(Object.keys(response.body).includes('data')).to.be.true; + }, + ); }); it('should listen for a GET request and respond with an empty body', () => { @@ -217,10 +223,12 @@ describe('Webhook Trigger node', async () => { }); ndv.actions.execute(); cy.wait(waitForWebhook); - cy.request('GET', `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then((response) => { - expect(response.status).to.eq(200); - expect(response.body.MyValue).to.be.undefined; - }); + cy.request<{ MyValue: unknown }>('GET', `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then( + (response) => { + expect(response.status).to.eq(200); + expect(response.body.MyValue).to.be.undefined; + }, + ); }); it('should listen for a GET request with Basic Authentication', () => { diff --git a/cypress/e2e/18-user-management.cy.ts b/cypress/e2e/18-user-management.cy.ts index fdd78cf1f214c..0332525ab9a29 100644 --- a/cypress/e2e/18-user-management.cy.ts +++ b/cypress/e2e/18-user-management.cy.ts @@ -187,7 +187,7 @@ describe('User Management', { disableAutoLogin: true }, () => { workflowPage.getters.successToast().should('contain', 'User deleted'); }); - it(`should allow user to change their personal data`, () => { + it('should allow user to change their personal data', () => { personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password); personalSettingsPage.actions.updateFirstAndLastName( updatedPersonalData.newFirstName, @@ -199,15 +199,15 @@ describe('User Management', { disableAutoLogin: true }, () => { workflowPage.getters.successToast().should('contain', 'Personal details updated'); }); - it(`shouldn't allow user to set weak password`, () => { + it("shouldn't allow user to set weak password", () => { personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password); personalSettingsPage.getters.changePasswordLink().click(); - for (let weakPass of updatedPersonalData.invalidPasswords) { + for (const weakPass of updatedPersonalData.invalidPasswords) { personalSettingsPage.actions.tryToSetWeakPassword(INSTANCE_OWNER.password, weakPass); } }); - it(`shouldn't allow user to change password if old password is wrong`, () => { + it("shouldn't allow user to change password if old password is wrong", () => { personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password); personalSettingsPage.getters.changePasswordLink().click(); personalSettingsPage.actions.updatePassword('iCannotRemember', updatedPersonalData.newPassword); @@ -217,7 +217,7 @@ describe('User Management', { disableAutoLogin: true }, () => { .should('contain', 'Provided current password is incorrect.'); }); - it(`should change current user password`, () => { + it('should change current user password', () => { personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password); personalSettingsPage.getters.changePasswordLink().click(); personalSettingsPage.actions.updatePassword( @@ -231,7 +231,7 @@ describe('User Management', { disableAutoLogin: true }, () => { ); }); - it(`shouldn't allow users to set invalid email`, () => { + it("shouldn't allow users to set invalid email", () => { personalSettingsPage.actions.loginAndVisit( INSTANCE_OWNER.email, updatedPersonalData.newPassword, @@ -242,7 +242,7 @@ describe('User Management', { disableAutoLogin: true }, () => { personalSettingsPage.actions.tryToSetInvalidEmail(updatedPersonalData.newEmail.split('.')[0]); }); - it(`should change user email`, () => { + it('should change user email', () => { personalSettingsPage.actions.loginAndVisit( INSTANCE_OWNER.email, updatedPersonalData.newPassword, diff --git a/cypress/e2e/19-execution.cy.ts b/cypress/e2e/19-execution.cy.ts index 84b71e08853de..921dc8bbca500 100644 --- a/cypress/e2e/19-execution.cy.ts +++ b/cypress/e2e/19-execution.cy.ts @@ -512,8 +512,9 @@ describe('Execution', () => { expect(interception.request.body).to.have.property('runData').that.is.an('object'); const expectedKeys = ['When clicking ‘Test workflow’', 'fetch 5 random users']; - expect(Object.keys(interception.request.body.runData)).to.have.lengthOf(expectedKeys.length); - expect(interception.request.body.runData).to.include.all.keys(expectedKeys); + const { runData } = interception.request.body as Record; + expect(Object.keys(runData)).to.have.lengthOf(expectedKeys.length); + expect(runData).to.include.all.keys(expectedKeys); }); }); @@ -537,10 +538,9 @@ describe('Execution', () => { expect(interception.request.body).to.have.property('pinData').that.is.an('object'); const expectedPinnedDataKeys = ['Webhook']; - expect(Object.keys(interception.request.body.pinData)).to.have.lengthOf( - expectedPinnedDataKeys.length, - ); - expect(interception.request.body.pinData).to.include.all.keys(expectedPinnedDataKeys); + const { pinData } = interception.request.body as Record; + expect(Object.keys(pinData)).to.have.lengthOf(expectedPinnedDataKeys.length); + expect(pinData).to.include.all.keys(expectedPinnedDataKeys); }); workflowPage.getters.clearExecutionDataButton().should('be.visible'); @@ -558,15 +558,12 @@ describe('Execution', () => { const expectedPinnedDataKeys = ['Webhook']; const expectedRunDataKeys = ['If', 'Webhook']; - expect(Object.keys(interception.request.body.pinData)).to.have.lengthOf( - expectedPinnedDataKeys.length, - ); - expect(interception.request.body.pinData).to.include.all.keys(expectedPinnedDataKeys); + const { pinData, runData } = interception.request.body as Record; + expect(Object.keys(pinData)).to.have.lengthOf(expectedPinnedDataKeys.length); + expect(pinData).to.include.all.keys(expectedPinnedDataKeys); - expect(Object.keys(interception.request.body.runData)).to.have.lengthOf( - expectedRunDataKeys.length, - ); - expect(interception.request.body.runData).to.include.all.keys(expectedRunDataKeys); + expect(Object.keys(runData)).to.have.lengthOf(expectedRunDataKeys.length); + expect(runData).to.include.all.keys(expectedRunDataKeys); }); }); @@ -617,6 +614,6 @@ describe('Execution', () => { .within(() => cy.get('.fa-check')) .should('exist'); - workflowPage.getters.errorToast().should('contain', `Problem in node ‘Telegram‘`); + workflowPage.getters.errorToast().should('contain', 'Problem in node ‘Telegram‘'); }); }); diff --git a/cypress/e2e/2-credentials.cy.ts b/cypress/e2e/2-credentials.cy.ts index 1a9776d280af1..cc10513ba709b 100644 --- a/cypress/e2e/2-credentials.cy.ts +++ b/cypress/e2e/2-credentials.cy.ts @@ -1,3 +1,4 @@ +import type { ICredentialType } from 'n8n-workflow'; import { GMAIL_NODE_NAME, HTTP_REQUEST_NODE_NAME, @@ -209,7 +210,7 @@ describe('Credentials', () => { req.headers['cache-control'] = 'no-cache, no-store'; req.on('response', (res) => { - const credentials = res.body || []; + const credentials: ICredentialType[] = res.body || []; const index = credentials.findIndex((c) => c.name === 'slackOAuth2Api'); diff --git a/cypress/e2e/20-workflow-executions.cy.ts b/cypress/e2e/20-workflow-executions.cy.ts index bf5ecb17b3e53..1d807695fd4ca 100644 --- a/cypress/e2e/20-workflow-executions.cy.ts +++ b/cypress/e2e/20-workflow-executions.cy.ts @@ -1,6 +1,6 @@ +import type { RouteHandler } from 'cypress/types/net-stubbing'; import { WorkflowPage } from '../pages'; import { WorkflowExecutionsTab } from '../pages/workflow-executions-tab'; -import type { RouteHandler } from 'cypress/types/net-stubbing'; import executionOutOfMemoryServerResponse from '../fixtures/responses/execution-out-of-memory-server-response.json'; const workflowPage = new WorkflowPage(); @@ -11,7 +11,7 @@ const executionsRefreshInterval = 4000; describe('Current Workflow Executions', () => { beforeEach(() => { workflowPage.actions.visit(); - cy.createFixtureWorkflow('Test_workflow_4_executions_view.json', `My test workflow`); + cy.createFixtureWorkflow('Test_workflow_4_executions_view.json', 'My test workflow'); }); it('should render executions tab correctly', () => { @@ -58,8 +58,8 @@ describe('Current Workflow Executions', () => { }); it('should not redirect back to execution tab when slow request is not done before leaving the page', () => { - const throttleResponse: RouteHandler = (req) => { - return new Promise((resolve) => { + const throttleResponse: RouteHandler = async (req) => { + return await new Promise((resolve) => { setTimeout(() => resolve(req.continue()), 2000); }); }; @@ -89,6 +89,7 @@ describe('Current Workflow Executions', () => { .should('be.visible') .its('0.contentDocument.body') // Access the body of the iframe document .should('not.be.empty') // Ensure the body is not empty + // eslint-disable-next-line @typescript-eslint/unbound-method .then(cy.wrap) .find('.el-notification:has(.el-notification--error)') .should('be.visible') diff --git a/cypress/e2e/21-community-nodes.cy.ts b/cypress/e2e/21-community-nodes.cy.ts index 39f572ba5c348..b9d10b30f2589 100644 --- a/cypress/e2e/21-community-nodes.cy.ts +++ b/cypress/e2e/21-community-nodes.cy.ts @@ -1,3 +1,4 @@ +import type { ICredentialType } from 'n8n-workflow'; import { NodeCreator } from '../pages/features/node-creator'; import CustomNodeFixture from '../fixtures/Custom_node.json'; import { CredentialsModal, WorkflowPage } from '../pages'; @@ -33,9 +34,9 @@ describe('Community Nodes', () => { req.headers['cache-control'] = 'no-cache, no-store'; req.on('response', (res) => { - const credentials = res.body || []; + const credentials: ICredentialType[] = res.body || []; - credentials.push(CustomCredential); + credentials.push(CustomCredential as ICredentialType); }); }); diff --git a/cypress/e2e/2106-ADO-pinned-data-execution-preview.cy.ts b/cypress/e2e/2106-ADO-pinned-data-execution-preview.cy.ts index 386d8762aa760..6c69f4f79d971 100644 --- a/cypress/e2e/2106-ADO-pinned-data-execution-preview.cy.ts +++ b/cypress/e2e/2106-ADO-pinned-data-execution-preview.cy.ts @@ -37,8 +37,9 @@ describe('ADO-2106 connections should be colored correctly for pinned data in ex .should('be.visible') .its('0.contentDocument.body') .should('not.be.empty') + // eslint-disable-next-line @typescript-eslint/unbound-method .then(cy.wrap) - .find(`.jtk-connector[data-source-node="Webhook"][data-target-node="Set"]`) + .find('.jtk-connector[data-source-node="Webhook"][data-target-node="Set"]') .should('have.class', 'success') .should('have.class', 'has-run') .should('not.have.class', 'pinned'); @@ -56,8 +57,9 @@ describe('ADO-2106 connections should be colored correctly for pinned data in ex .should('be.visible') .its('0.contentDocument.body') .should('not.be.empty') + // eslint-disable-next-line @typescript-eslint/unbound-method .then(cy.wrap) - .find(`.jtk-connector[data-source-node="Webhook"][data-target-node="Set"]`) + .find('.jtk-connector[data-source-node="Webhook"][data-target-node="Set"]') .should('have.class', 'success') .should('have.class', 'has-run') .should('have.class', 'pinned'); diff --git a/cypress/e2e/2230-ADO-ndv-reset-data-pagination.cy.ts b/cypress/e2e/2230-ADO-ndv-reset-data-pagination.cy.ts index 5fce1778480a6..849b520c4c792 100644 --- a/cypress/e2e/2230-ADO-ndv-reset-data-pagination.cy.ts +++ b/cypress/e2e/2230-ADO-ndv-reset-data-pagination.cy.ts @@ -7,7 +7,7 @@ describe('ADO-2230 NDV Pagination Reset', () => { it('should reset pagaintion if data size changes to less than current page', () => { // setup, load workflow with debughelper node with random seed workflowPage.actions.visit(); - cy.createFixtureWorkflow('NDV-debug-generate-data.json', `Debug workflow`); + cy.createFixtureWorkflow('NDV-debug-generate-data.json', 'Debug workflow'); workflowPage.actions.openNode('DebugHelper'); // execute node outputting 10 pages, check output of first page diff --git a/cypress/e2e/24-ndv-paired-item.cy.ts b/cypress/e2e/24-ndv-paired-item.cy.ts index 1b2b4f1efeaad..3d57a33a3b22e 100644 --- a/cypress/e2e/24-ndv-paired-item.cy.ts +++ b/cypress/e2e/24-ndv-paired-item.cy.ts @@ -1,5 +1,5 @@ -import { WorkflowPage, NDV } from '../pages'; import { v4 as uuid } from 'uuid'; +import { WorkflowPage, NDV } from '../pages'; const workflowPage = new WorkflowPage(); const ndv = new NDV(); diff --git a/cypress/e2e/25-stickies.cy.ts b/cypress/e2e/25-stickies.cy.ts index 4cbad810f915e..35416ebd3e42d 100644 --- a/cypress/e2e/25-stickies.cy.ts +++ b/cypress/e2e/25-stickies.cy.ts @@ -1,7 +1,7 @@ +import type { Interception } from 'cypress/types/net-stubbing'; import { META_KEY } from '../constants'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; import { getPopper } from '../utils'; -import { Interception } from 'cypress/types/net-stubbing'; const workflowPage = new WorkflowPageClass(); @@ -91,7 +91,7 @@ describe('Canvas Actions', () => { getPopper().should('be.visible'); - workflowPage.actions.pickColor(2); + workflowPage.actions.pickColor(); workflowPage.actions.toggleColorPalette(); @@ -301,15 +301,6 @@ function stickyShouldBePositionedCorrectly(position: Position) { }); } -function stickyShouldHaveCorrectSize(size: [number, number]) { - const yOffset = 0; - const xOffset = 0; - workflowPage.getters.stickies().should(($el) => { - expect($el).to.have.css('height', `${yOffset + size[0]}px`); - expect($el).to.have.css('width', `${xOffset + size[1]}px`); - }); -} - function moveSticky(target: Position) { cy.drag('[data-test-id="sticky"]', [target.left, target.top], { abs: true }); stickyShouldBePositionedCorrectly(target); diff --git a/cypress/e2e/27-two-factor-authentication.cy.ts b/cypress/e2e/27-two-factor-authentication.cy.ts index 91f6ca57a24cc..7a1ee28f2266b 100644 --- a/cypress/e2e/27-two-factor-authentication.cy.ts +++ b/cypress/e2e/27-two-factor-authentication.cy.ts @@ -1,9 +1,9 @@ -import { MainSidebar } from './../pages/sidebar/main-sidebar'; +import generateOTPToken from 'cypress-otp'; import { INSTANCE_OWNER, INSTANCE_ADMIN, BACKEND_BASE_URL } from '../constants'; import { SigninPage } from '../pages'; import { PersonalSettingsPage } from '../pages/settings-personal'; import { MfaLoginPage } from '../pages/mfa-login'; -import generateOTPToken from 'cypress-otp'; +import { MainSidebar } from './../pages/sidebar/main-sidebar'; const MFA_SECRET = 'KVKFKRCPNZQUYMLXOVYDSQKJKZDTSRLD'; @@ -36,14 +36,14 @@ const mainSidebar = new MainSidebar(); describe('Two-factor authentication', () => { beforeEach(() => { - Cypress.session.clearAllSavedSessions(); + void Cypress.session.clearAllSavedSessions(); cy.request('POST', `${BACKEND_BASE_URL}/rest/e2e/reset`, { owner: user, members: [], admin, }); - cy.on('uncaught:exception', (err, runnable) => { - expect(err.message).to.include('Not logged in'); + cy.on('uncaught:exception', (error) => { + expect(error.message).to.include('Not logged in'); return false; }); cy.intercept('GET', '/rest/mfa/qr').as('getMfaQrCode'); diff --git a/cypress/e2e/30-editor-after-route-changes.cy.ts b/cypress/e2e/30-editor-after-route-changes.cy.ts index a502d3577c5b5..423c92110b178 100644 --- a/cypress/e2e/30-editor-after-route-changes.cy.ts +++ b/cypress/e2e/30-editor-after-route-changes.cy.ts @@ -188,7 +188,7 @@ describe('Editor zoom should work after route changes', () => { cy.enableFeature('workflowHistory'); cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password }); workflowPage.actions.visit(); - cy.createFixtureWorkflow('Lots_of_nodes.json', `Lots of nodes`); + cy.createFixtureWorkflow('Lots_of_nodes.json', 'Lots of nodes'); workflowPage.actions.saveWorkflowOnButtonClick(); }); diff --git a/cypress/e2e/30-langchain.cy.ts b/cypress/e2e/30-langchain.cy.ts index 9536b3cf60b5a..c1409a34f379b 100644 --- a/cypress/e2e/30-langchain.cy.ts +++ b/cypress/e2e/30-langchain.cy.ts @@ -1,17 +1,4 @@ -import { - AGENT_NODE_NAME, - MANUAL_CHAT_TRIGGER_NODE_NAME, - AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, - MANUAL_TRIGGER_NODE_NAME, - AI_MEMORY_WINDOW_BUFFER_MEMORY_NODE_NAME, - AI_TOOL_CALCULATOR_NODE_NAME, - AI_OUTPUT_PARSER_AUTO_FIXING_NODE_NAME, - AI_TOOL_CODE_NODE_NAME, - AI_TOOL_WIKIPEDIA_NODE_NAME, - BASIC_LLM_CHAIN_NODE_NAME, - EDIT_FIELDS_SET_NODE_NAME, -} from './../constants'; -import { createMockNodeExecutionData, runMockWorkflowExcution } from '../utils'; +import { createMockNodeExecutionData, runMockWorkflowExecution } from '../utils'; import { addLanguageModelNodeToParent, addMemoryNodeToParent, @@ -42,6 +29,19 @@ import { getManualChatModalLogsTree, sendManualChatMessage, } from '../composables/modals/chat-modal'; +import { + AGENT_NODE_NAME, + MANUAL_CHAT_TRIGGER_NODE_NAME, + AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, + MANUAL_TRIGGER_NODE_NAME, + AI_MEMORY_WINDOW_BUFFER_MEMORY_NODE_NAME, + AI_TOOL_CALCULATOR_NODE_NAME, + AI_OUTPUT_PARSER_AUTO_FIXING_NODE_NAME, + AI_TOOL_CODE_NODE_NAME, + AI_TOOL_WIKIPEDIA_NODE_NAME, + BASIC_LLM_CHAIN_NODE_NAME, + EDIT_FIELDS_SET_NODE_NAME, +} from './../constants'; describe('Langchain Integration', () => { beforeEach(() => { @@ -149,7 +149,7 @@ describe('Langchain Integration', () => { const outputMessage = 'Hi there! How can I assist you today?'; clickExecuteNode(); - runMockWorkflowExcution({ + runMockWorkflowExecution({ trigger: () => sendManualChatMessage(inputMessage), runData: [ createMockNodeExecutionData(BASIC_LLM_CHAIN_NODE_NAME, { @@ -189,7 +189,7 @@ describe('Langchain Integration', () => { const outputMessage = 'Hi there! How can I assist you today?'; clickExecuteNode(); - runMockWorkflowExcution({ + runMockWorkflowExecution({ trigger: () => sendManualChatMessage(inputMessage), runData: [ createMockNodeExecutionData(AGENT_NODE_NAME, { @@ -230,7 +230,7 @@ describe('Langchain Integration', () => { const inputMessage = 'Hello!'; const outputMessage = 'Hi there! How can I assist you today?'; - runMockWorkflowExcution({ + runMockWorkflowExecution({ trigger: () => { sendManualChatMessage(inputMessage); }, diff --git a/cypress/e2e/32-node-io-filter.cy.ts b/cypress/e2e/32-node-io-filter.cy.ts index 3f1ffdf005243..bc39c1b611b53 100644 --- a/cypress/e2e/32-node-io-filter.cy.ts +++ b/cypress/e2e/32-node-io-filter.cy.ts @@ -6,7 +6,7 @@ const ndv = new NDV(); describe('Node IO Filter', () => { beforeEach(() => { workflowPage.actions.visit(); - cy.createFixtureWorkflow('Node_IO_filter.json', `Node IO filter`); + cy.createFixtureWorkflow('Node_IO_filter.json', 'Node IO filter'); workflowPage.actions.saveWorkflowOnButtonClick(); workflowPage.actions.executeWorkflow(); }); diff --git a/cypress/e2e/33-settings-personal.cy.ts b/cypress/e2e/33-settings-personal.cy.ts index 9257bee22d25f..58e0f05237869 100644 --- a/cypress/e2e/33-settings-personal.cy.ts +++ b/cypress/e2e/33-settings-personal.cy.ts @@ -1,4 +1,4 @@ -import { WorkflowPage } from "../pages"; +import { WorkflowPage } from '../pages'; const workflowPage = new WorkflowPage(); @@ -27,7 +27,7 @@ const VALID_NAMES = [ ]; describe('Personal Settings', () => { - it ('should allow to change first and last name', () => { + it('should allow to change first and last name', () => { cy.visit('/settings/personal'); VALID_NAMES.forEach((name) => { cy.getByTestId('personal-data-form').find('input[name="firstName"]').clear().type(name[0]); diff --git a/cypress/e2e/34-template-credentials-setup.cy.ts b/cypress/e2e/34-template-credentials-setup.cy.ts index e112cd926b875..7553a55a7b9df 100644 --- a/cypress/e2e/34-template-credentials-setup.cy.ts +++ b/cypress/e2e/34-template-credentials-setup.cy.ts @@ -50,7 +50,7 @@ describe('Template credentials setup', () => { clickUseWorkflowButtonByTitle('Promote new Shopify products on Twitter and Telegram'); templateCredentialsSetupPage.getters - .title(`Set up 'Promote new Shopify products on Twitter and Telegram' template`) + .title("Set up 'Promote new Shopify products on Twitter and Telegram' template") .should('be.visible'); }); @@ -58,7 +58,7 @@ describe('Template credentials setup', () => { templateCredentialsSetupPage.visitTemplateCredentialSetupPage(testTemplate.id); templateCredentialsSetupPage.getters - .title(`Set up 'Promote new Shopify products on Twitter and Telegram' template`) + .title("Set up 'Promote new Shopify products on Twitter and Telegram' template") .should('be.visible'); templateCredentialsSetupPage.getters diff --git a/cypress/e2e/39-import-workflow.cy.ts b/cypress/e2e/39-import-workflow.cy.ts index 831228fba35d9..5b78b7c65d097 100644 --- a/cypress/e2e/39-import-workflow.cy.ts +++ b/cypress/e2e/39-import-workflow.cy.ts @@ -64,7 +64,7 @@ describe('Import workflow', () => { workflowPage.getters.workflowMenuItemImportFromFile().click(); workflowPage.getters .workflowImportInput() - .selectFile('cypress/fixtures/Test_workflow-actions_paste-data.json', { force: true }); + .selectFile('fixtures/Test_workflow-actions_paste-data.json', { force: true }); cy.waitForLoad(false); workflowPage.actions.zoomToFit(); workflowPage.getters.canvasNodes().should('have.length', 5); diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts index 21685dd58cf6c..96e98a00312d0 100644 --- a/cypress/e2e/39-projects.cy.ts +++ b/cypress/e2e/39-projects.cy.ts @@ -1,4 +1,10 @@ -import { INSTANCE_ADMIN, INSTANCE_MEMBERS, INSTANCE_OWNER, MANUAL_TRIGGER_NODE_NAME, NOTION_NODE_NAME } from '../constants'; +import { + INSTANCE_ADMIN, + INSTANCE_MEMBERS, + INSTANCE_OWNER, + MANUAL_TRIGGER_NODE_NAME, + NOTION_NODE_NAME, +} from '../constants'; import { WorkflowsPage, WorkflowPage, @@ -260,7 +266,9 @@ describe('Projects', () => { credentialsModal.getters.newCredentialTypeSelect().should('be.visible'); credentialsModal.getters.newCredentialTypeOption('Notion API').click(); credentialsModal.getters.newCredentialTypeButton().click(); - credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890'); + credentialsModal.getters + .connectionParameter('Internal Integration Secret') + .type('1234567890'); credentialsModal.actions.setName('Notion account project 1'); cy.intercept('POST', '/rest/credentials').as('credentialSave'); @@ -283,7 +291,9 @@ describe('Projects', () => { credentialsModal.getters.newCredentialTypeSelect().should('be.visible'); credentialsModal.getters.newCredentialTypeOption('Notion API').click(); credentialsModal.getters.newCredentialTypeButton().click(); - credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890'); + credentialsModal.getters + .connectionParameter('Internal Integration Secret') + .type('1234567890'); credentialsModal.actions.setName('Notion account project 2'); credentialsModal.actions.save(); @@ -303,12 +313,14 @@ describe('Projects', () => { credentialsModal.getters.newCredentialTypeSelect().should('be.visible'); credentialsModal.getters.newCredentialTypeOption('Notion API').click(); credentialsModal.getters.newCredentialTypeButton().click(); - credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890'); + credentialsModal.getters + .connectionParameter('Internal Integration Secret') + .type('1234567890'); credentialsModal.actions.setName('Notion account personal project'); cy.intercept('POST', '/rest/credentials').as('credentialSave'); credentialsModal.actions.save(); - cy.wait('@credentialSave') + cy.wait('@credentialSave'); credentialsModal.actions.close(); // Go to the first project and create a workflow @@ -318,14 +330,22 @@ describe('Projects', () => { workflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); workflowPage.getters.nodeCredentialsSelect().first().click(); - getVisibleSelect().find('li').should('have.length', 2).first().should('contain.text', 'Notion account project 1'); + getVisibleSelect() + .find('li') + .should('have.length', 2) + .first() + .should('contain.text', 'Notion account project 1'); ndv.getters.backToCanvas().click(); workflowPage.actions.saveWorkflowOnButtonClick(); cy.reload(); workflowPage.getters.canvasNodeByName(NOTION_NODE_NAME).should('be.visible').dblclick(); workflowPage.getters.nodeCredentialsSelect().first().click(); - getVisibleSelect().find('li').should('have.length', 2).first().should('contain.text', 'Notion account project 1'); + getVisibleSelect() + .find('li') + .should('have.length', 2) + .first() + .should('contain.text', 'Notion account project 1'); ndv.getters.backToCanvas().click(); // Go to the second project and create a workflow @@ -335,14 +355,22 @@ describe('Projects', () => { workflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); workflowPage.getters.nodeCredentialsSelect().first().click(); - getVisibleSelect().find('li').should('have.length', 2).first().should('contain.text', 'Notion account project 2'); + getVisibleSelect() + .find('li') + .should('have.length', 2) + .first() + .should('contain.text', 'Notion account project 2'); ndv.getters.backToCanvas().click(); workflowPage.actions.saveWorkflowOnButtonClick(); cy.reload(); workflowPage.getters.canvasNodeByName(NOTION_NODE_NAME).should('be.visible').dblclick(); workflowPage.getters.nodeCredentialsSelect().first().click(); - getVisibleSelect().find('li').should('have.length', 2).first().should('contain.text', 'Notion account project 2'); + getVisibleSelect() + .find('li') + .should('have.length', 2) + .first() + .should('contain.text', 'Notion account project 2'); ndv.getters.backToCanvas().click(); // Go to the Home project and create a workflow @@ -356,15 +384,22 @@ describe('Projects', () => { workflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true); workflowPage.getters.nodeCredentialsSelect().first().click(); - getVisibleSelect().find('li').should('have.length', 2).first().should('contain.text', 'Notion account personal project'); + getVisibleSelect() + .find('li') + .should('have.length', 2) + .first() + .should('contain.text', 'Notion account personal project'); ndv.getters.backToCanvas().click(); workflowPage.actions.saveWorkflowOnButtonClick(); cy.reload(); workflowPage.getters.canvasNodeByName(NOTION_NODE_NAME).should('be.visible').dblclick(); workflowPage.getters.nodeCredentialsSelect().first().click(); - getVisibleSelect().find('li').should('have.length', 2).first().should('contain.text', 'Notion account personal project'); - + getVisibleSelect() + .find('li') + .should('have.length', 2) + .first() + .should('contain.text', 'Notion account personal project'); }); }); }); diff --git a/cypress/e2e/41-editors.cy.ts b/cypress/e2e/41-editors.cy.ts index 0c44c5118540c..f7ad8129b37db 100644 --- a/cypress/e2e/41-editors.cy.ts +++ b/cypress/e2e/41-editors.cy.ts @@ -12,7 +12,6 @@ describe('Editors', () => { }); describe('SQL Editor', () => { - it('should preserve changes when opening-closing Postgres node', () => { workflowPage.actions.addInitialNodeToCanvas('Postgres', { action: 'Execute a SQL query', @@ -26,7 +25,11 @@ describe('Editors', () => { .type('{esc}'); ndv.actions.close(); workflowPage.actions.openNode('Postgres'); - ndv.getters.sqlEditorContainer().find('.cm-content').type('{end} LIMIT 10', { delay: TYPING_DELAY }).type('{esc}'); + ndv.getters + .sqlEditorContainer() + .find('.cm-content') + .type('{end} LIMIT 10', { delay: TYPING_DELAY }) + .type('{esc}'); ndv.actions.close(); workflowPage.actions.openNode('Postgres'); ndv.getters.sqlEditorContainer().should('contain', 'SELECT * FROM `testTable` LIMIT 10'); @@ -126,7 +129,11 @@ describe('Editors', () => { .type('{esc}'); ndv.actions.close(); workflowPage.actions.openNode('HTML'); - ndv.getters.htmlEditorContainer().find('.cm-content').type(`{end}${TEST_ELEMENT_P}`, { delay: TYPING_DELAY, force: true }).type('{esc}'); + ndv.getters + .htmlEditorContainer() + .find('.cm-content') + .type(`{end}${TEST_ELEMENT_P}`, { delay: TYPING_DELAY, force: true }) + .type('{esc}'); ndv.actions.close(); workflowPage.actions.openNode('HTML'); ndv.getters.htmlEditorContainer().should('contain', TEST_ELEMENT_H1); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 0ebd859174e85..7623e2de979cb 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -67,7 +67,7 @@ describe('NDV', () => { }); it('should disconect Switch outputs if rules order was changed', () => { - cy.createFixtureWorkflow('NDV-test-switch_reorder.json', `NDV test switch reorder`); + cy.createFixtureWorkflow('NDV-test-switch_reorder.json', 'NDV test switch reorder'); workflowPage.actions.zoomToFit(); workflowPage.actions.executeWorkflow(); @@ -305,7 +305,7 @@ describe('NDV', () => { it('should display parameter hints correctly', () => { workflowPage.actions.visit(); - cy.createFixtureWorkflow('Test_workflow_3.json', `My test workflow`); + cy.createFixtureWorkflow('Test_workflow_3.json', 'My test workflow'); workflowPage.actions.openNode('Set1'); ndv.actions.typeIntoParameterInput('value', '='); // switch to expressions @@ -333,7 +333,7 @@ describe('NDV', () => { } ndv.getters.parameterInput('name').click(); // remove focus from input, hide expression preview - ndv.actions.validateExpressionPreview('value', output || input); + ndv.actions.validateExpressionPreview('value', output ?? input); ndv.getters.parameterInput('value').clear(); }); }); @@ -436,7 +436,7 @@ describe('NDV', () => { } it('should traverse floating nodes with mouse', () => { - cy.createFixtureWorkflow('Floating_Nodes.json', `Floating Nodes`); + cy.createFixtureWorkflow('Floating_Nodes.json', 'Floating Nodes'); workflowPage.getters.canvasNodes().first().dblclick(); getFloatingNodeByPosition('inputMain').should('not.exist'); getFloatingNodeByPosition('outputMain').should('exist'); @@ -482,7 +482,7 @@ describe('NDV', () => { }); it('should traverse floating nodes with keyboard', () => { - cy.createFixtureWorkflow('Floating_Nodes.json', `Floating Nodes`); + cy.createFixtureWorkflow('Floating_Nodes.json', 'Floating Nodes'); workflowPage.getters.canvasNodes().first().dblclick(); getFloatingNodeByPosition('inputMain').should('not.exist'); getFloatingNodeByPosition('outputMain').should('exist'); @@ -597,7 +597,7 @@ describe('NDV', () => { }); it('Should render xml and html tags as strings and can search', () => { - cy.createFixtureWorkflow('Test_workflow_xml_output.json', `test`); + cy.createFixtureWorkflow('Test_workflow_xml_output.json', 'test'); workflowPage.actions.executeWorkflow(); @@ -741,7 +741,7 @@ describe('NDV', () => { it('should allow selecting item for expressions', () => { workflowPage.actions.visit(); - cy.createFixtureWorkflow('Test_workflow_3.json', `My test workflow`); + cy.createFixtureWorkflow('Test_workflow_3.json', 'My test workflow'); workflowPage.actions.openNode('Set'); ndv.actions.typeIntoParameterInput('value', '='); // switch to expressions diff --git a/cypress/e2e/7-workflow-actions.cy.ts b/cypress/e2e/7-workflow-actions.cy.ts index c0875d93f73f8..0ba151f4cc3fd 100644 --- a/cypress/e2e/7-workflow-actions.cy.ts +++ b/cypress/e2e/7-workflow-actions.cy.ts @@ -338,7 +338,6 @@ describe('Workflow Actions', () => { cy.get('body').type(META_KEY, { delay: 500, release: false }).type('{enter}'); WorkflowPage.getters.successToast().should('not.exist'); }); - }); describe('Menu entry Push To Git', () => { diff --git a/cypress/e2e/9-expression-editor-modal.cy.ts b/cypress/e2e/9-expression-editor-modal.cy.ts index ddf4ac3090285..30b6a84957b30 100644 --- a/cypress/e2e/9-expression-editor-modal.cy.ts +++ b/cypress/e2e/9-expression-editor-modal.cy.ts @@ -8,7 +8,7 @@ describe('Expression editor modal', () => { beforeEach(() => { WorkflowPage.actions.visit(); WorkflowPage.actions.addInitialNodeToCanvas('Schedule'); - cy.on('uncaught:exception', (err) => err.name !== 'ExpressionError'); + cy.on('uncaught:exception', (error) => error.name !== 'ExpressionError'); }); describe('Static data', () => { diff --git a/cypress/package.json b/cypress/package.json new file mode 100644 index 0000000000000..aabcc929c5bd5 --- /dev/null +++ b/cypress/package.json @@ -0,0 +1,28 @@ +{ + "name": "n8n-cypress", + "private": true, + "scripts": { + "typecheck": "tsc --noEmit", + "cypress:install": "cypress install", + "test:e2e:ui": "scripts/run-e2e.js ui", + "test:e2e:dev": "scripts/run-e2e.js dev", + "test:e2e:all": "scripts/run-e2e.js all", + "format": "prettier --write . --ignore-path ../.prettierignore", + "lint": "eslint . --quiet", + "lintfix": "eslint . --fix", + "start": "cd ..; pnpm start" + }, + "devDependencies": { + "@types/uuid": "^8.3.2", + "n8n-workflow": "workspace:*" + }, + "dependencies": { + "@ngneat/falso": "^6.4.0", + "cross-env": "^7.0.3", + "cypress": "^13.6.2", + "cypress-otp": "^1.0.3", + "cypress-real-events": "^1.11.0", + "start-server-and-test": "^2.0.3", + "uuid": "8.3.2" + } +} diff --git a/cypress/pages/bannerStack.ts b/cypress/pages/bannerStack.ts index dce3222126018..c4936891ae69e 100644 --- a/cypress/pages/bannerStack.ts +++ b/cypress/pages/bannerStack.ts @@ -4,5 +4,6 @@ export class BannerStack extends BasePage { getters = { banner: () => cy.getByTestId('banner-stack'), }; + actions = {}; } diff --git a/cypress/pages/base.ts b/cypress/pages/base.ts index 06c832591c549..abd7a210a8807 100644 --- a/cypress/pages/base.ts +++ b/cypress/pages/base.ts @@ -1,6 +1,7 @@ -import { IE2ETestPage, IE2ETestPageElement } from '../types'; +import type { IE2ETestPage } from '../types'; export class BasePage implements IE2ETestPage { - getters: Record = {}; - actions: Record void> = {}; + getters = {}; + + actions = {}; } diff --git a/cypress/pages/credentials.ts b/cypress/pages/credentials.ts index 7ae2d0f3b415c..a356dc1421347 100644 --- a/cypress/pages/credentials.ts +++ b/cypress/pages/credentials.ts @@ -2,6 +2,7 @@ import { BasePage } from './base'; export class CredentialsPage extends BasePage { url = '/home/credentials'; + getters = { emptyListCreateCredentialButton: () => cy.getByTestId('empty-resources-list').find('button'), createCredentialButton: () => cy.getByTestId('resources-list-add'), @@ -23,6 +24,7 @@ export class CredentialsPage extends BasePage { filtersTrigger: () => cy.getByTestId('resources-list-filters-trigger'), filtersDropdown: () => cy.getByTestId('resources-list-filters-dropdown'), }; + actions = { search: (searchString: string) => { const searchInput = this.getters.searchInput(); diff --git a/cypress/pages/demo.ts b/cypress/pages/demo.ts index 0590fb8def03e..691066ce05c9d 100644 --- a/cypress/pages/demo.ts +++ b/cypress/pages/demo.ts @@ -7,15 +7,14 @@ export function vistDemoPage(theme?: 'dark' | 'light') { cy.visit('/workflows/demo' + query); cy.waitForLoad(); cy.window().then((win) => { - // @ts-ignore win.preventNodeViewBeforeUnload = true; }); } export function importWorkflow(workflow: object) { - const OPEN_WORKFLOW = {command: 'openWorkflow', workflow}; - cy.window().then($window => { + const OPEN_WORKFLOW = { command: 'openWorkflow', workflow }; + cy.window().then(($window) => { const message = JSON.stringify(OPEN_WORKFLOW); - $window.postMessage(message, '*') + $window.postMessage(message, '*'); }); } diff --git a/cypress/pages/features/node-creator.ts b/cypress/pages/features/node-creator.ts index 3e6a819443c8f..a0d39951607ac 100644 --- a/cypress/pages/features/node-creator.ts +++ b/cypress/pages/features/node-creator.ts @@ -1,8 +1,8 @@ import { BasePage } from '../base'; -import { INodeTypeDescription } from 'n8n-workflow'; export class NodeCreator extends BasePage { url = '/workflow/new'; + getters = { plusButton: () => cy.getByTestId('node-creator-plus-button'), canvasAddButton: () => cy.getByTestId('canvas-add-button'), @@ -25,6 +25,7 @@ export class NodeCreator extends BasePage { expandedCategories: () => this.getters.creatorItem().find('>div').filter('.active').invoke('text'), }; + actions = { openNodeCreator: () => { this.getters.plusButton().click(); @@ -33,31 +34,5 @@ export class NodeCreator extends BasePage { selectNode: (displayName: string) => { this.getters.getCreatorItem(displayName).click(); }, - toggleCategory: (category: string) => { - this.getters.getCreatorItem(category).click(); - }, - categorizeNodes: (nodes: INodeTypeDescription[]) => { - const categorizedNodes = nodes.reduce((acc, node) => { - const categories = (node?.codex?.categories || []).map((category: string) => - category.trim(), - ); - - categories.forEach((category: { [key: string]: INodeTypeDescription[] }) => { - // Node creator should show only the latest version of a node - const newerVersion = nodes.find( - (n: INodeTypeDescription) => - n.name === node.name && (n.version > node.version || Array.isArray(n.version)), - ); - - if (acc[category] === undefined) { - acc[category] = []; - } - acc[category].push(newerVersion ?? node); - }); - return acc; - }, {}); - - return categorizedNodes; - }, }; } diff --git a/cypress/pages/mfa-login.ts b/cypress/pages/mfa-login.ts index 50ca5adab7f98..ae4d916ba911c 100644 --- a/cypress/pages/mfa-login.ts +++ b/cypress/pages/mfa-login.ts @@ -5,6 +5,7 @@ import { WorkflowsPage } from './workflows'; export class MfaLoginPage extends BasePage { url = '/mfa'; + getters = { form: () => cy.getByTestId('mfa-login-form'), token: () => cy.getByTestId('token'), diff --git a/cypress/pages/modals/credentials-modal.ts b/cypress/pages/modals/credentials-modal.ts index 2275ea5e4ccad..9492f59bbe3fd 100644 --- a/cypress/pages/modals/credentials-modal.ts +++ b/cypress/pages/modals/credentials-modal.ts @@ -28,6 +28,7 @@ export class CredentialsModal extends BasePage { usersSelect: () => cy.getByTestId('project-sharing-select').filter(':visible'), testSuccessTag: () => cy.getByTestId('credentials-config-container-test-success'), }; + actions = { addUser: (email: string) => { this.getters.usersSelect().click(); @@ -45,7 +46,7 @@ export class CredentialsModal extends BasePage { if (test) cy.wait('@testCredential'); this.getters.saveButton().should('contain.text', 'Saved'); }, - saveSharing: (test = false) => { + saveSharing: () => { cy.intercept('PUT', '/rest/credentials/*/share').as('shareCredential'); this.getters.saveButton().click({ force: true }); cy.wait('@shareCredential'); diff --git a/cypress/pages/modals/message-box.ts b/cypress/pages/modals/message-box.ts index b54e375ef6c2a..a40c2d1a88f4e 100644 --- a/cypress/pages/modals/message-box.ts +++ b/cypress/pages/modals/message-box.ts @@ -8,6 +8,7 @@ export class MessageBox extends BasePage { confirm: () => this.getters.modal().find('.btn--confirm').first(), cancel: () => this.getters.modal().find('.btn--cancel').first(), }; + actions = { confirm: () => { this.getters.confirm().click({ force: true }); diff --git a/cypress/pages/modals/workflow-sharing-modal.ts b/cypress/pages/modals/workflow-sharing-modal.ts index fc4ba8dada1fc..02e183fc812f1 100644 --- a/cypress/pages/modals/workflow-sharing-modal.ts +++ b/cypress/pages/modals/workflow-sharing-modal.ts @@ -7,6 +7,7 @@ export class WorkflowSharingModal extends BasePage { saveButton: () => cy.getByTestId('workflow-sharing-modal-save-button'), closeButton: () => this.getters.modal().find('.el-dialog__close').first(), }; + actions = { addUser: (email: string) => { this.getters.usersSelect().click(); diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index 0cb7609fb2305..651c58feb35af 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -1,5 +1,5 @@ -import { BasePage } from './base'; import { getVisiblePopper, getVisibleSelect } from '../utils'; +import { BasePage } from './base'; export class NDV extends BasePage { getters = { @@ -158,12 +158,9 @@ export class NDV extends BasePage { this.getters.pinnedDataEditor().click(); this.getters .pinnedDataEditor() - .type( - `{selectall}{backspace}${pinnedData.replace(new RegExp('{', 'g'), '{{}')}`, - { - delay: 0, - }, - ); + .type(`{selectall}{backspace}${pinnedData.replace(new RegExp('{', 'g'), '{{}')}`, { + delay: 0, + }); this.actions.savePinnedData(); }, @@ -179,7 +176,7 @@ export class NDV extends BasePage { this.actions.savePinnedData(); }, clearParameterInput: (parameterName: string) => { - this.getters.parameterInput(parameterName).type(`{selectall}{backspace}`); + this.getters.parameterInput(parameterName).type('{selectall}{backspace}'); }, typeIntoParameterInput: ( parameterName: string, @@ -188,7 +185,7 @@ export class NDV extends BasePage { ) => { this.getters.parameterInput(parameterName).type(content, opts); }, - selectOptionInParameterDropdown: (parameterName: string, content: string) => { + selectOptionInParameterDropdown: (_: string, content: string) => { getVisibleSelect().find('.option-headline').contains(content).click(); }, rename: (newName: string) => { @@ -286,7 +283,7 @@ export class NDV extends BasePage { parseSpecialCharSequences: false, delay, }); - this.actions.validateExpressionPreview(fieldName, `node doesn't exist`); + this.actions.validateExpressionPreview(fieldName, "node doesn't exist"); }, openSettings: () => { this.getters.nodeSettingsTab().click(); diff --git a/cypress/pages/settings-log-streaming.ts b/cypress/pages/settings-log-streaming.ts index 2d056a4444c04..cc1ea1250d80d 100644 --- a/cypress/pages/settings-log-streaming.ts +++ b/cypress/pages/settings-log-streaming.ts @@ -1,8 +1,9 @@ -import { BasePage } from './base'; import { getVisibleSelect } from '../utils'; +import { BasePage } from './base'; export class SettingsLogStreamingPage extends BasePage { url = '/settings/log-streaming'; + getters = { getActionBoxUnlicensed: () => cy.getByTestId('action-box-unlicensed'), getActionBoxLicensed: () => cy.getByTestId('action-box-licensed'), @@ -17,6 +18,7 @@ export class SettingsLogStreamingPage extends BasePage { getDestinationDeleteButton: () => cy.getByTestId('destination-delete-button'), getDestinationCards: () => cy.getByTestId('destination-card'), }; + actions = { clickContactUs: () => this.getters.getContactUsButton().click(), clickAddFirstDestination: () => this.getters.getAddFirstDestinationButton().click(), diff --git a/cypress/pages/settings-personal.ts b/cypress/pages/settings-personal.ts index 716625beb5ab6..69227603dbc78 100644 --- a/cypress/pages/settings-personal.ts +++ b/cypress/pages/settings-personal.ts @@ -1,13 +1,14 @@ +import generateOTPToken from 'cypress-otp'; import { ChangePasswordModal } from './modals/change-password-modal'; import { MfaSetupModal } from './modals/mfa-setup-modal'; import { BasePage } from './base'; -import generateOTPToken from 'cypress-otp'; const changePasswordModal = new ChangePasswordModal(); const mfaSetupModal = new MfaSetupModal(); export class PersonalSettingsPage extends BasePage { url = '/settings/personal'; + secret = ''; getters = { @@ -23,6 +24,7 @@ export class PersonalSettingsPage extends BasePage { themeSelector: () => cy.getByTestId('theme-select'), selectOptionsVisible: () => cy.get('.el-select-dropdown:visible .el-select-dropdown__item'), }; + actions = { changeTheme: (theme: 'System default' | 'Dark' | 'Light') => { this.getters.themeSelector().click(); diff --git a/cypress/pages/settings-usage.ts b/cypress/pages/settings-usage.ts index cd3dfd3596090..85300fe05f9ea 100644 --- a/cypress/pages/settings-usage.ts +++ b/cypress/pages/settings-usage.ts @@ -2,6 +2,8 @@ import { BasePage } from './base'; export class SettingsUsagePage extends BasePage { url = '/settings/usage'; + getters = {}; + actions = {}; } diff --git a/cypress/pages/settings-users.ts b/cypress/pages/settings-users.ts index a16eb4ab6f1bf..d188896225b04 100644 --- a/cypress/pages/settings-users.ts +++ b/cypress/pages/settings-users.ts @@ -11,6 +11,7 @@ const settingsSidebar = new SettingsSidebar(); export class SettingsUsersPage extends BasePage { url = '/settings/users'; + getters = { setUpOwnerButton: () => cy.getByTestId('action-box').find('button').first(), inviteButton: () => cy.getByTestId('settings-users-invite-button').last(), @@ -34,6 +35,7 @@ export class SettingsUsersPage extends BasePage { deleteUserButton: () => this.getters.confirmDeleteModal().find('button:contains("Delete")'), deleteDataInput: () => cy.getByTestId('delete-data-input').find('input').first(), }; + actions = { goToOwnerSetup: () => this.getters.setUpOwnerButton().click(), loginAndVisit: (email: string, password: string, isOwner: boolean) => { diff --git a/cypress/pages/settings.ts b/cypress/pages/settings.ts index 264b525dee717..74c3b0fe76967 100644 --- a/cypress/pages/settings.ts +++ b/cypress/pages/settings.ts @@ -2,8 +2,10 @@ import { BasePage } from './base'; export class SettingsPage extends BasePage { url = '/settings'; + getters = { menuItems: () => cy.getByTestId('menu-item'), }; + actions = {}; } diff --git a/cypress/pages/sidebar/main-sidebar.ts b/cypress/pages/sidebar/main-sidebar.ts index 7b30de7a4e3b4..1ce7eb54d7e0a 100644 --- a/cypress/pages/sidebar/main-sidebar.ts +++ b/cypress/pages/sidebar/main-sidebar.ts @@ -1,8 +1,6 @@ import { BasePage } from '../base'; import { WorkflowsPage } from '../workflows'; -const workflowsPage = new WorkflowsPage(); - export class MainSidebar extends BasePage { getters = { menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id), @@ -16,6 +14,7 @@ export class MainSidebar extends BasePage { userMenu: () => cy.get('div[class="action-dropdown-container"]'), logo: () => cy.getByTestId('n8n-logo'), }; + actions = { goToSettings: () => { this.getters.settings().should('be.visible'); diff --git a/cypress/pages/sidebar/settings-sidebar.ts b/cypress/pages/sidebar/settings-sidebar.ts index 886a0a3c1ef67..17d43b65e7573 100644 --- a/cypress/pages/sidebar/settings-sidebar.ts +++ b/cypress/pages/sidebar/settings-sidebar.ts @@ -6,6 +6,7 @@ export class SettingsSidebar extends BasePage { users: () => this.getters.menuItem('settings-users'), back: () => cy.getByTestId('settings-back'), }; + actions = { goToUsers: () => { this.getters.users().should('be.visible'); diff --git a/cypress/pages/signin.ts b/cypress/pages/signin.ts index 1b2b35c22fbb4..22d0fd163aaa1 100644 --- a/cypress/pages/signin.ts +++ b/cypress/pages/signin.ts @@ -4,6 +4,7 @@ import { WorkflowsPage } from './workflows'; export class SigninPage extends BasePage { url = '/signin'; + getters = { form: () => cy.getByTestId('auth-form'), email: () => cy.getByTestId('email'), diff --git a/cypress/pages/template-credential-setup.ts b/cypress/pages/template-credential-setup.ts index d673261fdf77b..0910d47632074 100644 --- a/cypress/pages/template-credential-setup.ts +++ b/cypress/pages/template-credential-setup.ts @@ -1,6 +1,6 @@ -import { CredentialsModal, MessageBox } from './modals'; import * as formStep from '../composables/setup-template-form-step'; import { overrideFeatureFlag } from '../composables/featureFlags'; +import { CredentialsModal, MessageBox } from './modals'; export type TemplateTestData = { id: number; diff --git a/cypress/pages/template-workflow.ts b/cypress/pages/template-workflow.ts index d1e8630a12428..84464d0ae60cb 100644 --- a/cypress/pages/template-workflow.ts +++ b/cypress/pages/template-workflow.ts @@ -17,15 +17,18 @@ export class TemplateWorkflowPage extends BasePage { this.getters.useTemplateButton().click(); }, - openTemplate: (template: { - workflow: { - id: number; - name: string; - description: string; - user: { username: string }; - image: { id: number; url: string }[]; - }; - }, templateHost: string) => { + openTemplate: ( + template: { + workflow: { + id: number; + name: string; + description: string; + user: { username: string }; + image: Array<{ id: number; url: string }>; + }; + }, + templateHost: string, + ) => { cy.intercept('GET', `${templateHost}/api/templates/workflows/${template.workflow.id}`, { statusCode: 200, body: template, diff --git a/cypress/pages/variables.ts b/cypress/pages/variables.ts index 6d9e9eb134694..c74624686eafb 100644 --- a/cypress/pages/variables.ts +++ b/cypress/pages/variables.ts @@ -3,6 +3,7 @@ import Chainable = Cypress.Chainable; export class VariablesPage extends BasePage { url = '/variables'; + getters = { unavailableResourcesList: () => cy.getByTestId('unavailable-resources-list'), emptyResourcesList: () => cy.getByTestId('empty-resources-list'), @@ -14,7 +15,7 @@ export class VariablesPage extends BasePage { createVariableButton: () => cy.getByTestId('resources-list-add'), variablesRows: () => cy.getByTestId('variables-row'), variablesEditableRows: () => - cy.getByTestId('variables-row').filter((index, row) => !!row.querySelector('input')), + cy.getByTestId('variables-row').filter((_, row) => !!row.querySelector('input')), variableRow: (key: string) => this.getters.variablesRows().contains(key).parents('[data-test-id="variables-row"]'), editableRowCancelButton: (row: Chainable>) => diff --git a/cypress/pages/workerView.ts b/cypress/pages/workerView.ts index e14bfd36a253b..f442468c52855 100644 --- a/cypress/pages/workerView.ts +++ b/cypress/pages/workerView.ts @@ -2,6 +2,7 @@ import { BasePage } from './base'; export class WorkerViewPage extends BasePage { url = '/settings/workers'; + getters = { workerCards: () => cy.getByTestId('worker-card'), workerCard: (workerId: string) => this.getters.workerCards().contains(workerId), diff --git a/cypress/pages/workflow-executions-tab.ts b/cypress/pages/workflow-executions-tab.ts index 474a00745e017..27285d28b8220 100644 --- a/cypress/pages/workflow-executions-tab.ts +++ b/cypress/pages/workflow-executions-tab.ts @@ -26,6 +26,7 @@ export class WorkflowExecutionsTab extends BasePage { executionDebugButton: () => cy.getByTestId('execution-debug-button'), workflowExecutionPreviewIframe: () => cy.getByTestId('workflow-preview-iframe'), }; + actions = { toggleNodeEnabled: (nodeName: string) => { workflowPage.getters.canvasNodeByName(nodeName).click(); diff --git a/cypress/pages/workflow-history.ts b/cypress/pages/workflow-history.ts index 18cd6ed999235..1b9d7328b1399 100644 --- a/cypress/pages/workflow-history.ts +++ b/cypress/pages/workflow-history.ts @@ -1,7 +1,7 @@ -import { BasePage } from "./base"; +import { BasePage } from './base'; export class WorkflowHistoryPage extends BasePage { - getters = { - workflowHistoryCloseButton: () => cy.getByTestId('workflow-history-close-button'), - } + getters = { + workflowHistoryCloseButton: () => cy.getByTestId('workflow-history-close-button'), + }; } diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index fa8867ce4e3bd..2a03c80c51c6d 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -1,6 +1,6 @@ import { META_KEY } from '../constants'; -import { BasePage } from './base'; import { getVisibleSelect } from '../utils'; +import { BasePage } from './base'; import { NodeCreator } from './features/node-creator'; type CyGetOptions = Parameters<(typeof cy)['get']>[1]; @@ -8,6 +8,7 @@ type CyGetOptions = Parameters<(typeof cy)['get']>[1]; const nodeCreator = new NodeCreator(); export class WorkflowPage extends BasePage { url = '/workflow/new'; + getters = { workflowNameInputContainer: () => cy.getByTestId('workflow-name-input', { timeout: 5000 }), workflowNameInput: () => @@ -134,12 +135,12 @@ export class WorkflowPage extends BasePage { colors: () => cy.getByTestId('color'), contextMenuAction: (action: string) => cy.getByTestId(`context-menu-item-${action}`), }; + actions = { visit: (preventNodeViewUnload = true) => { cy.visit(this.url); cy.waitForLoad(); cy.window().then((win) => { - // @ts-ignore win.preventNodeViewBeforeUnload = preventNodeViewUnload; }); }, @@ -329,15 +330,17 @@ export class WorkflowPage extends BasePage { cy.getByTestId('zoom-to-fit').click(); }, pinchToZoom: (steps: number, mode: 'zoomIn' | 'zoomOut' = 'zoomIn') => { - // Pinch-to-zoom simulates a 'wheel' event with ctrlKey: true (same as zooming by scrolling) - this.getters.nodeViewBackground().trigger('wheel', { - force: true, - bubbles: true, - ctrlKey: true, - pageX: cy.window().innerWidth / 2, - pageY: cy.window().innerHeight / 2, - deltaMode: 1, - deltaY: mode === 'zoomOut' ? steps : -steps, + cy.window().then((win) => { + // Pinch-to-zoom simulates a 'wheel' event with ctrlKey: true (same as zooming by scrolling) + this.getters.nodeViewBackground().trigger('wheel', { + force: true, + bubbles: true, + ctrlKey: true, + pageX: win.innerWidth / 2, + pageY: win.innerHeight / 2, + deltaMode: 1, + deltaY: mode === 'zoomOut' ? steps : -steps, + }); }); }, hitUndo: () => { @@ -388,11 +391,7 @@ export class WorkflowPage extends BasePage { this.actions.addNodeToCanvas(newNodeName, false, false, action); }, - deleteNodeBetweenNodes: ( - sourceNodeName: string, - targetNodeName: string, - newNodeName: string, - ) => { + deleteNodeBetweenNodes: (sourceNodeName: string, targetNodeName: string) => { this.getters.getConnectionBetweenNodes(sourceNodeName, targetNodeName).first().realHover(); this.getters .getConnectionActionsBetweenNodes(sourceNodeName, targetNodeName) @@ -415,7 +414,7 @@ export class WorkflowPage extends BasePage { .find('[data-test-id="change-sticky-color"]') .click({ force: true }); }, - pickColor: (index: number) => { + pickColor: () => { this.getters.colors().eq(1).click(); }, editSticky: (content: string) => { diff --git a/cypress/pages/workflows.ts b/cypress/pages/workflows.ts index 370a2ae0e3e71..356b9ca5ecdd4 100644 --- a/cypress/pages/workflows.ts +++ b/cypress/pages/workflows.ts @@ -2,6 +2,7 @@ import { BasePage } from './base'; export class WorkflowsPage extends BasePage { url = '/home/workflows'; + getters = { newWorkflowButtonCard: () => cy.getByTestId('new-workflow-card'), newWorkflowTemplateCard: () => cy.getByTestId('new-workflow-template-card'), diff --git a/scripts/run-e2e.js b/cypress/scripts/run-e2e.js similarity index 100% rename from scripts/run-e2e.js rename to cypress/scripts/run-e2e.js diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index bd33a8f21f678..3bb28e2df8053 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -16,9 +16,7 @@ Cypress.Commands.add('createFixtureWorkflow', (fixtureKey, workflowName) => { const workflowPage = new WorkflowPage(); // We need to force the click because the input is hidden - workflowPage.getters - .workflowImportInput() - .selectFile(`cypress/fixtures/${fixtureKey}`, { force: true }); + workflowPage.getters.workflowImportInput().selectFile(`fixtures/${fixtureKey}`, { force: true }); cy.waitForLoad(false); workflowPage.actions.setWorkflowName(workflowName); @@ -46,7 +44,7 @@ Cypress.Commands.add('waitForLoad', (waitForIntercepts = true) => { }); Cypress.Commands.add('signin', ({ email, password }) => { - Cypress.session.clearAllSavedSessions(); + void Cypress.session.clearAllSavedSessions(); cy.session([email, password], () => cy.request({ method: 'POST', @@ -128,7 +126,7 @@ Cypress.Commands.add('paste', { prevSubject: true }, (selector, pastePayload) => }); Cypress.Commands.add('drag', (selector, pos, options) => { - const index = options?.index || 0; + const index = options?.index ?? 0; const [xDiff, yDiff] = pos; const element = typeof selector === 'string' ? cy.get(selector).eq(index) : selector; element.should('exist'); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 69bb74ec88b0c..0cf29b09a433d 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -4,8 +4,8 @@ import './commands'; before(() => { cy.resetDatabase(); - Cypress.on('uncaught:exception', (err) => { - return !err.message.includes('ResizeObserver'); + Cypress.on('uncaught:exception', (error) => { + return !error.message.includes('ResizeObserver'); }); }); diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 411b73225037d..f25104f883d8d 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -1,7 +1,7 @@ // Load type definitions that come with Cypress module /// -import { Interception } from 'cypress/types/net-stubbing'; +import type { Interception } from 'cypress/types/net-stubbing'; interface SigninPayload { email: string; @@ -18,7 +18,7 @@ declare global { config(key: keyof SuiteConfigOverrides): boolean; getByTestId( selector: string, - ...args: (Partial | undefined)[] + ...args: Array | undefined> ): Chainable>; findChildByTestId(childTestId: string): Chainable>; createFixtureWorkflow(fixtureKey: string, workflowName: string): void; @@ -36,7 +36,7 @@ declare global { readClipboard(): Chainable; paste(pastePayload: string): void; drag( - selector: string | Cypress.Chainable>, + selector: string | Chainable>, target: [number, number], options?: { abs?: boolean; index?: number; realMouse?: boolean; clickToFinish?: boolean }, ): void; @@ -45,8 +45,11 @@ declare global { shouldNotHaveConsoleErrors(): void; window(): Chainable< AUTWindow & { + innerWidth: number; + innerHeight: number; + preventNodeViewBeforeUnload?: boolean; featureFlags: { - override: (feature: string, value: any) => void; + override: (feature: string, value: unknown) => void; }; } >; diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index 26a5da716b47e..dc823f5e5cfb3 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -7,5 +7,6 @@ "types": ["cypress", "node"] }, "include": ["**/*.ts"], - "exclude": ["**/dist/**/*", "**/node_modules/**/*"] + "exclude": ["**/dist/**/*", "**/node_modules/**/*"], + "references": [{ "path": "../packages/workflow/tsconfig.build.json" }] } diff --git a/cypress/types.ts b/cypress/types.ts index d7e776d49a74f..6186c4201d405 100644 --- a/cypress/types.ts +++ b/cypress/types.ts @@ -1,12 +1,24 @@ export type IE2ETestPageElement = ( - ...args: any[] + ...args: unknown[] ) => | Cypress.Chainable> | Cypress.Chainable> | Cypress.Chainable>; +type Getter = IE2ETestPageElement | ((key: string | number) => IE2ETestPageElement); + export interface IE2ETestPage { url?: string; - getters: Record; - actions: Record void>; + getters: Record; + actions: Record void>; +} + +interface Execution { + workflowId: string; +} + +export interface ExecutionResponse { + data: { + results: Execution[]; + }; } diff --git a/cypress/utils/executions.ts b/cypress/utils/executions.ts index d88b58ea9bfeb..4a40eff8fe393 100644 --- a/cypress/utils/executions.ts +++ b/cypress/utils/executions.ts @@ -1,5 +1,4 @@ -import { ITaskData } from '../../packages/workflow/src'; -import { IPinData } from '../../packages/workflow'; +import type { IDataObject, IPinData, ITaskData, ITaskDataConnections } from 'n8n-workflow'; import { clickExecuteWorkflowButton } from '../composables/workflow'; export function createMockNodeExecutionData( @@ -10,7 +9,7 @@ export function createMockNodeExecutionData( executionStatus = 'success', jsonData, ...rest - }: Partial & { jsonData?: Record }, + }: Partial & { jsonData?: Record }, ): Record { return { [name]: { @@ -29,7 +28,7 @@ export function createMockNodeExecutionData( ]; return acc; - }, {}) + }, {} as ITaskDataConnections) : data, source: [null], ...rest, @@ -75,7 +74,7 @@ export function createMockWorkflowExecutionData({ }; } -export function runMockWorkflowExcution({ +export function runMockWorkflowExecution({ trigger, lastNodeExecuted, runData, @@ -105,7 +104,7 @@ export function runMockWorkflowExcution({ cy.wait('@runWorkflow'); - const resolvedRunData = {}; + const resolvedRunData: Record = {}; runData.forEach((nodeExecution) => { const nodeName = Object.keys(nodeExecution)[0]; const nodeRunData = nodeExecution[nodeName]; diff --git a/package.json b/package.json index d82bf6247b765..124b672571264 100644 --- a/package.json +++ b/package.json @@ -31,23 +31,13 @@ "test:frontend": "pnpm --filter=@n8n/chat --filter=@n8n/codemirror-lang --filter=n8n-design-system --filter=n8n-editor-ui test", "watch": "turbo run watch --parallel", "webhook": "./packages/cli/bin/n8n webhook", - "worker": "./packages/cli/bin/n8n worker", - "cypress:install": "cypress install", - "cypress:open": "CYPRESS_BASE_URL=http://localhost:8080 cypress open", - "test:e2e:ui": "scripts/run-e2e.js ui", - "test:e2e:dev": "scripts/run-e2e.js dev", - "test:e2e:all": "scripts/run-e2e.js all" + "worker": "./packages/cli/bin/n8n worker" }, "devDependencies": { "@n8n_io/eslint-config": "workspace:*", - "@ngneat/falso": "^6.4.0", "@types/jest": "^29.5.3", "@types/supertest": "^6.0.2", "@vitest/coverage-v8": "^1.6.0", - "cross-env": "^7.0.3", - "cypress": "^13.6.2", - "cypress-otp": "^1.0.3", - "cypress-real-events": "^1.11.0", "jest": "^29.6.2", "jest-environment-jsdom": "^29.6.2", "jest-expect-message": "^1.1.3", @@ -58,7 +48,6 @@ "p-limit": "^3.1.0", "rimraf": "^5.0.1", "run-script-os": "^1.0.7", - "start-server-and-test": "^2.0.3", "supertest": "^7.0.0", "ts-jest": "^29.1.1", "tsc-alias": "^1.8.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2ddd7d6da463..e0a71cdaed553 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,9 +50,6 @@ importers: '@n8n_io/eslint-config': specifier: workspace:* version: link:packages/@n8n_io/eslint-config - '@ngneat/falso': - specifier: ^6.4.0 - version: 6.4.0 '@types/jest': specifier: ^29.5.3 version: 29.5.3 @@ -62,18 +59,6 @@ importers: '@vitest/coverage-v8': specifier: ^1.6.0 version: 1.6.0(vitest@1.6.0(@types/node@18.16.16)(jsdom@23.0.1)(sass@1.64.1)(terser@5.16.1)) - cross-env: - specifier: ^7.0.3 - version: 7.0.3 - cypress: - specifier: ^13.6.2 - version: 13.6.2 - cypress-otp: - specifier: ^1.0.3 - version: 1.0.3 - cypress-real-events: - specifier: ^1.11.0 - version: 1.11.0(cypress@13.6.2) jest: specifier: ^29.6.2 version: 29.6.2(@types/node@18.16.16) @@ -104,9 +89,6 @@ importers: run-script-os: specifier: ^1.0.7 version: 1.1.6 - start-server-and-test: - specifier: ^2.0.3 - version: 2.0.3 supertest: specifier: ^7.0.0 version: 7.0.0 @@ -138,6 +120,37 @@ importers: specifier: ^2.0.19 version: 2.0.19(typescript@5.4.2) + cypress: + dependencies: + '@ngneat/falso': + specifier: ^6.4.0 + version: 6.4.0 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + cypress: + specifier: ^13.6.2 + version: 13.6.2 + cypress-otp: + specifier: ^1.0.3 + version: 1.0.3 + cypress-real-events: + specifier: ^1.11.0 + version: 1.11.0(cypress@13.6.2) + start-server-and-test: + specifier: ^2.0.3 + version: 2.0.3 + uuid: + specifier: 8.3.2 + version: 8.3.2 + devDependencies: + '@types/uuid': + specifier: ^8.3.2 + version: 8.3.4 + n8n-workflow: + specifier: workspace:* + version: link:../packages/workflow + packages/@n8n/chat: dependencies: highlight.js: @@ -5733,9 +5746,6 @@ packages: '@types/uuid@8.3.4': resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} - '@types/uuid@9.0.0': - resolution: {integrity: sha512-kr90f+ERiQtKWMz5rP32ltJ/BtULDI5RVO0uavn1HQUOwjx0R1h0rnDYNL0CepF1zL5bSY6FISAfd9tOdDhU5Q==} - '@types/uuid@9.0.7': resolution: {integrity: sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==} @@ -13156,10 +13166,6 @@ packages: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true - uuid@9.0.0: - resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==} - hasBin: true - uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -13341,6 +13347,9 @@ packages: vue-component-type-helpers@2.0.19: resolution: {integrity: sha512-cN3f1aTxxKo4lzNeQAkVopswuImUrb5Iurll9Gaw5cqpnbTAxtEMM1mgi6ou4X79OCyqYv1U1mzBHJkzmiK82w==} + vue-component-type-helpers@2.0.21: + resolution: {integrity: sha512-3NaicyZ7N4B6cft4bfb7dOnPbE9CjLcx+6wZWAg5zwszfO4qXRh+U52dN5r5ZZfc6iMaxKCEcoH9CmxxoFZHLg==} + vue-demi@0.14.5: resolution: {integrity: sha512-o9NUVpl/YlsGJ7t+xuqJKx8EBGf1quRhCiT6D/J0pfwmk9zUwYkC7yrF4SZCe6fETvSM3UNL2edcbYrSyc4QHA==} engines: {node: '>=12'} @@ -18969,7 +18978,7 @@ snapshots: ts-dedent: 2.2.0 type-fest: 2.19.0 vue: 3.4.21(typescript@5.4.2) - vue-component-type-helpers: 2.0.19 + vue-component-type-helpers: 2.0.21 transitivePeerDependencies: - encoding - prettier @@ -19573,8 +19582,6 @@ snapshots: '@types/uuid@8.3.4': {} - '@types/uuid@9.0.0': {} - '@types/uuid@9.0.7': {} '@types/validator@13.7.10': {} @@ -21459,7 +21466,7 @@ snapshots: cli-table3: 0.6.3 commander: 6.2.1 common-tags: 1.8.2 - dayjs: 1.11.6 + dayjs: 1.11.10 debug: 4.3.4(supports-color@8.1.1) enquirer: 2.3.6 eventemitter2: 6.4.7 @@ -24542,11 +24549,11 @@ snapshots: dependencies: '@types/asn1': 0.2.0 '@types/node': 18.16.16 - '@types/uuid': 9.0.0 + '@types/uuid': 9.0.7 asn1: 0.2.6 debug: 4.3.4(supports-color@8.1.1) strict-event-emitter-types: 2.0.0 - uuid: 9.0.0 + uuid: 9.0.1 transitivePeerDependencies: - supports-color @@ -28334,8 +28341,6 @@ snapshots: uuid@8.3.2: {} - uuid@9.0.0: {} - uuid@9.0.1: {} v3-infinite-loading@1.2.2: {} @@ -28515,6 +28520,8 @@ snapshots: vue-component-type-helpers@2.0.19: {} + vue-component-type-helpers@2.0.21: {} + vue-demi@0.14.5(vue@3.4.21(typescript@5.4.2)): dependencies: vue: 3.4.21(typescript@5.4.2) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ce18506b3820a..add43b6c008b6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,4 @@ packages: - packages/* - packages/@n8n/* - packages/@n8n_io/* + - cypress From 99b54bb0296a855f6bbaf1183b8a554dcf072bb7 Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Mon, 10 Jun 2024 16:54:13 +0300 Subject: [PATCH 10/20] =?UTF-8?q?fix(editor):=20Fix=20node=20connection=20?= =?UTF-8?q?showing=20incorrect=20item=20count=20during=20=E2=80=A6=20(#968?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/editor-ui/src/components/Node.vue | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/editor-ui/src/components/Node.vue b/packages/editor-ui/src/components/Node.vue index 9dd4af5130d11..2668281e86acd 100644 --- a/packages/editor-ui/src/components/Node.vue +++ b/packages/editor-ui/src/components/Node.vue @@ -658,12 +658,15 @@ export default defineComponent({ this.showTriggerNodeTooltip = false; } }, - nodeRunData(newValue) { - if (!this.data) { - return; - } + nodeRunData: { + deep: true, + handler(newValue) { + if (!this.data) { + return; + } - this.$emit('run', { name: this.data.name, data: newValue, waiting: !!this.waiting }); + this.$emit('run', { name: this.data.name, data: newValue, waiting: !!this.waiting }); + }, }, }, created() { From b652405a0614e45d051268bb05051b454da21d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Mon, 10 Jun 2024 16:00:21 +0200 Subject: [PATCH 11/20] fix(editor): Prevent saving already saved workflows (#9670) --- cypress/e2e/7-workflow-actions.cy.ts | 14 ++++++++++++++ packages/editor-ui/src/views/NodeView.vue | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/cypress/e2e/7-workflow-actions.cy.ts b/cypress/e2e/7-workflow-actions.cy.ts index 0ba151f4cc3fd..362b86ee8971c 100644 --- a/cypress/e2e/7-workflow-actions.cy.ts +++ b/cypress/e2e/7-workflow-actions.cy.ts @@ -36,6 +36,20 @@ describe('Workflow Actions', () => { WorkflowPage.getters.isWorkflowSaved(); }); + it('should not save already saved workflow', () => { + cy.intercept('PATCH', '/rest/workflows/*').as('saveWorkflow'); + WorkflowPage.actions.saveWorkflowOnButtonClick(); + WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); + WorkflowPage.actions.saveWorkflowOnButtonClick(); + cy.wait('@saveWorkflow'); + WorkflowPage.getters.isWorkflowSaved(); + // Try to save a few times + WorkflowPage.actions.saveWorkflowUsingKeyboardShortcut(); + WorkflowPage.actions.saveWorkflowUsingKeyboardShortcut(); + // Should be saved only once + cy.get('@saveWorkflow.all').should('have.length', 1); + }); + it('should not be able to activate unsaved workflow', () => { WorkflowPage.getters.activatorSwitch().find('input').first().should('be.disabled'); }); diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 3cd20391760da..8189058d27ced 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -1572,8 +1572,9 @@ export default defineComponent({ if (e.key === 's' && ctrlModifier && !readOnly) { e.stopPropagation(); e.preventDefault(); + const workflowIsSaved = !this.uiStore.stateIsDirty; - if (this.isReadOnlyRoute || this.readOnlyEnv) { + if (this.isReadOnlyRoute || this.readOnlyEnv || workflowIsSaved) { return; } From ae00b446a79e86cf570287c904fd6dde41ddf71a Mon Sep 17 00:00:00 2001 From: Giulio Andreini Date: Mon, 10 Jun 2024 16:02:47 +0200 Subject: [PATCH 12/20] fix(editor): Node background for executing nodes in dark mode (#9682) --- packages/design-system/src/css/_tokens.dark.scss | 8 ++++++-- packages/design-system/src/css/_tokens.scss | 8 ++++++-- packages/editor-ui/src/components/NDVFloatingNodes.vue | 2 +- .../editor-ui/src/components/NDVSubConnections.vue | 2 +- packages/editor-ui/src/components/Node.vue | 10 +++++----- .../nodes/render-types/CanvasNodeConfigurable.vue | 2 +- .../elements/nodes/render-types/CanvasNodeDefault.vue | 2 +- packages/editor-ui/src/n8n-theme-variables.scss | 7 ------- 8 files changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/design-system/src/css/_tokens.dark.scss b/packages/design-system/src/css/_tokens.dark.scss index 2f20b13f95d1f..5aa56c2677f86 100644 --- a/packages/design-system/src/css/_tokens.dark.scss +++ b/packages/design-system/src/css/_tokens.dark.scss @@ -47,9 +47,13 @@ --color-canvas-background-l: 18%; --color-canvas-dot: var(--prim-gray-670); --color-canvas-read-only-line: var(--prim-gray-800); - --color-canvas-node-background: var(--prim-gray-740); - --color-canvas-node-pinned-border: var(--prim-color-secondary-tint-100); --color-canvas-selected: var(--prim-gray-0-alpha-025); + + // Nodes + --color-node-background: var(--prim-gray-740); + --color-node-executing-background: var(--prim-gray-670); + --color-node-executing-other-background: var(--prim-gray-670); + --color-node-pinned-border: var(--prim-color-secondary-tint-100); --node-type-main-color: var(--prim-gray-420); // Sticky diff --git a/packages/design-system/src/css/_tokens.scss b/packages/design-system/src/css/_tokens.scss index 6a0293e609f01..ed8b5ac490306 100644 --- a/packages/design-system/src/css/_tokens.scss +++ b/packages/design-system/src/css/_tokens.scss @@ -79,9 +79,13 @@ --color-canvas-background-l: 99%; --color-canvas-dot: var(--prim-gray-120); --color-canvas-read-only-line: var(--prim-gray-30); - --color-canvas-node-background: var(--color-background-xlight); - --color-canvas-node-pinned-border: var(--color-secondary); --color-canvas-selected: var(--prim-gray-70); + + // Nodes + --color-node-background: var(--color-background-xlight); + --color-node-executing-background: var(--color-primary-tint-3); + --color-node-executing-other-background: var(--color-primary-tint-3); + --color-node-pinned-border: var(--color-secondary); --node-type-main-color: var(--prim-gray-490); // Sticky diff --git a/packages/editor-ui/src/components/NDVFloatingNodes.vue b/packages/editor-ui/src/components/NDVFloatingNodes.vue index fd1aa2d054e58..626ca63191a4c 100644 --- a/packages/editor-ui/src/components/NDVFloatingNodes.vue +++ b/packages/editor-ui/src/components/NDVFloatingNodes.vue @@ -202,7 +202,7 @@ defineExpose({ } .connectedNode { border: var(--border-base); - background-color: var(--color-canvas-node-background); + background-color: var(--color-node-background); border-radius: 100%; padding: var(--spacing-s); cursor: pointer; diff --git a/packages/editor-ui/src/components/NDVSubConnections.vue b/packages/editor-ui/src/components/NDVSubConnections.vue index 66032fe96dcae..d8187df358fda 100644 --- a/packages/editor-ui/src/components/NDVSubConnections.vue +++ b/packages/editor-ui/src/components/NDVSubConnections.vue @@ -406,7 +406,7 @@ defineExpose({ } .connectedNode { border: var(--border-base); - background-color: var(--color-canvas-node-background); + background-color: var(--color-node-background); border-radius: 100%; padding: var(--spacing-xs); cursor: pointer; diff --git a/packages/editor-ui/src/components/Node.vue b/packages/editor-ui/src/components/Node.vue index 2668281e86acd..03f19d7b9626f 100644 --- a/packages/editor-ui/src/components/Node.vue +++ b/packages/editor-ui/src/components/Node.vue @@ -563,7 +563,7 @@ export default defineComponent({ returnStyles['border-style'] = 'solid'; } } else if (!!this.waiting || this.showPinnedDataInfo) { - borderColor = '--color-canvas-node-pinned-border'; + borderColor = '--color-node-pinned-border'; } else if (this.nodeExecutionStatus === 'unknown') { borderColor = '--color-foreground-xdark'; } else if (this.workflowDataItems) { @@ -909,10 +909,10 @@ export default defineComponent({ height: 100%; border: 2px solid var(--color-foreground-xdark); border-radius: var(--border-radius-large); - background-color: var(--color-canvas-node-background); - --color-background-node-icon-badge: var(--color-canvas-node-background); + background-color: var(--color-node-background); + --color-background-node-icon-badge: var(--color-node-background); &.executing { - background-color: $node-background-executing !important; + background-color: var(--color-node-executing-background) !important; .node-executing-info { display: inline-block; @@ -993,7 +993,7 @@ export default defineComponent({ border-radius: 50px; &.executing { - background-color: $node-background-executing-other !important; + background-color: var(--color-node-executing-other-background) !important; } .node-executing-info { diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeConfigurable.vue b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeConfigurable.vue index 6a8a5d4facc05..fb4554abb1795 100644 --- a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeConfigurable.vue +++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeConfigurable.vue @@ -63,7 +63,7 @@ const styles = computed(() => { display: flex; align-items: center; justify-content: center; - background: var(--canvas-node--background, var(--color-canvas-node-background)); + background: var(--canvas-node--background, var(--color-node-background)); border: 2px solid var(--canvas-node--border-color, var(--color-foreground-xdark)); border-radius: var(--border-radius-large); } diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue index 0fee29e777f64..7a301a8ce975c 100644 --- a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue +++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue @@ -44,7 +44,7 @@ const styles = computed(() => { display: flex; align-items: center; justify-content: center; - background: var(--canvas-node--background, var(--color-canvas-node-background)); + background: var(--canvas-node--background, var(--color-node-background)); border: 2px solid var(--canvas-node--border-color, var(--color-foreground-xdark)); border-radius: var(--border-radius-large); } diff --git a/packages/editor-ui/src/n8n-theme-variables.scss b/packages/editor-ui/src/n8n-theme-variables.scss index eb29815eddf78..b1cfe2ce58fee 100644 --- a/packages/editor-ui/src/n8n-theme-variables.scss +++ b/packages/editor-ui/src/n8n-theme-variables.scss @@ -62,13 +62,6 @@ $tag-text-color: var(--color-text-dark); $tag-close-background-color: var(--color-text-light); $tag-close-background-hover-color: var(--color-text-dark); -// nodes -$node-background-default: var(--color-background-xlight); -$node-background-executing: var(--color-primary-tint-3); -$node-background-executing-other: #ede9ff; -// TODO: Define that differently -$node-background-type-other: #ede9ff; - // Node creator $node-creator-width: 385px; $node-creator-text-color: var(--color-text-dark); From 74b6215df519acd4c1e3373a9e5553ad132a6657 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 10 Jun 2024 16:21:47 +0200 Subject: [PATCH 13/20] refactor(core): Use `@Licensed()` in event bus controller (no-changelog) (#9687) --- .../cli/src/eventbus/eventBus.controller.ee.ts | 15 +++++++++------ .../logStreamingEnabled.middleware.ee.ts | 15 --------------- 2 files changed, 9 insertions(+), 21 deletions(-) delete mode 100644 packages/cli/src/eventbus/middleware/logStreamingEnabled.middleware.ee.ts diff --git a/packages/cli/src/eventbus/eventBus.controller.ee.ts b/packages/cli/src/eventbus/eventBus.controller.ee.ts index 95433a359b334..980c1e5edbcde 100644 --- a/packages/cli/src/eventbus/eventBus.controller.ee.ts +++ b/packages/cli/src/eventbus/eventBus.controller.ee.ts @@ -5,7 +5,7 @@ import type { } from 'n8n-workflow'; import { MessageEventBusDestinationTypeNames } from 'n8n-workflow'; -import { RestController, Get, Post, Delete, GlobalScope } from '@/decorators'; +import { RestController, Get, Post, Delete, GlobalScope, Licensed } from '@/decorators'; import { AuthenticatedRequest } from '@/requests'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; @@ -20,7 +20,6 @@ import { } from './MessageEventBusDestination/MessageEventBusDestinationSyslog.ee'; import { MessageEventBusDestinationWebhook } from './MessageEventBusDestination/MessageEventBusDestinationWebhook.ee'; import type { MessageEventBusDestination } from './MessageEventBusDestination/MessageEventBusDestination.ee'; -import { logStreamingLicensedMiddleware } from './middleware/logStreamingEnabled.middleware.ee'; // ---------------------------------------- // TypeGuards @@ -60,7 +59,8 @@ export class EventBusControllerEE { // Destinations // ---------------------------------------- - @Get('/destination', { middlewares: [logStreamingLicensedMiddleware] }) + @Licensed('feat:logStreaming') + @Get('/destination') @GlobalScope('eventBusDestination:list') async getDestination(req: express.Request): Promise { if (isWithIdString(req.query)) { @@ -70,7 +70,8 @@ export class EventBusControllerEE { } } - @Post('/destination', { middlewares: [logStreamingLicensedMiddleware] }) + @Licensed('feat:logStreaming') + @Post('/destination') @GlobalScope('eventBusDestination:create') async postDestination(req: AuthenticatedRequest): Promise { let result: MessageEventBusDestination | undefined; @@ -114,7 +115,8 @@ export class EventBusControllerEE { throw new BadRequestError('Body is not configuring MessageEventBusDestinationOptions'); } - @Get('/testmessage', { middlewares: [logStreamingLicensedMiddleware] }) + @Licensed('feat:logStreaming') + @Get('/testmessage') @GlobalScope('eventBusDestination:test') async sendTestMessage(req: express.Request): Promise { if (isWithIdString(req.query)) { @@ -123,7 +125,8 @@ export class EventBusControllerEE { return false; } - @Delete('/destination', { middlewares: [logStreamingLicensedMiddleware] }) + @Licensed('feat:logStreaming') + @Delete('/destination') @GlobalScope('eventBusDestination:delete') async deleteDestination(req: AuthenticatedRequest) { if (isWithIdString(req.query)) { diff --git a/packages/cli/src/eventbus/middleware/logStreamingEnabled.middleware.ee.ts b/packages/cli/src/eventbus/middleware/logStreamingEnabled.middleware.ee.ts deleted file mode 100644 index 4589032ca135d..0000000000000 --- a/packages/cli/src/eventbus/middleware/logStreamingEnabled.middleware.ee.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { RequestHandler } from 'express'; -import Container from 'typedi'; -import { License } from '../../License'; - -export function islogStreamingLicensed(): boolean { - return Container.get(License).isLogStreamingEnabled(); -} - -export const logStreamingLicensedMiddleware: RequestHandler = (_req, res, next) => { - if (islogStreamingLicensed()) { - next(); - } else { - res.status(403).json({ status: 'error', message: 'Unauthorized' }); - } -}; From 2521daadfcb3ff36b42c776f1ea3c5f56586cc03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Mon, 10 Jun 2024 17:18:41 +0200 Subject: [PATCH 14/20] ci: Fix e2e tests (no-changelog) (#9689) --- .github/workflows/e2e-reusable.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml index 771dfab35a9be..49c37fd4c896b 100644 --- a/.github/workflows/e2e-reusable.yml +++ b/.github/workflows/e2e-reusable.yml @@ -166,8 +166,7 @@ jobs: # We have to provide custom ci-build-id key to make sure that this workflow could be run multiple times # in the same parent workflow ci-build-id: ${{ needs.prepare.outputs.uuid }} - spec: '/__w/n8n/n8n/cypress/${{ inputs.spec }}' - config-file: /__w/n8n/n8n/cypress/cypress.config.js + spec: '${{ inputs.spec }}' env: NODE_OPTIONS: --dns-result-order=ipv4first CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} From 817167cf4b6dd8c8a110014037363a55cbcf2122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Mon, 10 Jun 2024 17:38:02 +0200 Subject: [PATCH 15/20] refactor(core): Merge event bus controllers and remove dead code (no-changelog) (#9688) --- packages/@n8n/permissions/src/types.ts | 3 - packages/cli/src/Server.ts | 2 - .../MessageEventBusLogWriter.ts | 9 - .../MessageEventBusLogWriterWorker.ts | 4 - .../src/eventbus/eventBus.controller.ee.ts | 138 -------------- .../cli/src/eventbus/eventBus.controller.ts | 180 ++++++++++-------- packages/cli/src/permissions/global-roles.ts | 7 - .../integration/shared/utils/testServer.ts | 2 - packages/editor-ui/src/api/eventbus.ee.ts | 4 - packages/editor-ui/src/stores/rbac.store.ts | 1 - .../editor-ui/src/stores/workflows.store.ts | 11 -- 11 files changed, 100 insertions(+), 261 deletions(-) delete mode 100644 packages/cli/src/eventbus/eventBus.controller.ee.ts diff --git a/packages/@n8n/permissions/src/types.ts b/packages/@n8n/permissions/src/types.ts index 2c6079203aaa9..2720272e6fd75 100644 --- a/packages/@n8n/permissions/src/types.ts +++ b/packages/@n8n/permissions/src/types.ts @@ -6,7 +6,6 @@ export type Resource = | 'credential' | 'externalSecretsProvider' | 'externalSecret' - | 'eventBusEvent' | 'eventBusDestination' | 'ldap' | 'license' @@ -45,7 +44,6 @@ export type EventBusDestinationScope = ResourceScope< 'eventBusDestination', DefaultOperations | 'test' >; -export type EventBusEventScope = ResourceScope<'eventBusEvent', DefaultOperations | 'query'>; export type LdapScope = ResourceScope<'ldap', 'manage' | 'sync'>; export type LicenseScope = ResourceScope<'license', 'manage'>; export type LogStreamingScope = ResourceScope<'logStreaming', 'manage'>; @@ -70,7 +68,6 @@ export type Scope = | CredentialScope | ExternalSecretProviderScope | ExternalSecretScope - | EventBusEventScope | EventBusDestinationScope | LdapScope | LicenseScope diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index fa30e8392d2e2..c6e30d76b8b81 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -47,7 +47,6 @@ import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import * as ResponseHelper from '@/ResponseHelper'; import { EventBusController } from '@/eventbus/eventBus.controller'; -import { EventBusControllerEE } from '@/eventbus/eventBus.controller.ee'; import { LicenseController } from '@/license/license.controller'; import { setupPushServer, setupPushHandler } from '@/push'; import { isLdapEnabled } from './Ldap/helpers'; @@ -119,7 +118,6 @@ export class Server extends AbstractServer { const controllers: Array> = [ EventBusController, - EventBusControllerEE, AuthController, LicenseController, OAuth1CredentialController, diff --git a/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts b/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts index 918e79e523ce3..7565cb481482b 100644 --- a/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts +++ b/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts @@ -97,15 +97,6 @@ export class MessageEventBusLogWriter { } } - /** - * Pauses all logging. Events are still received by the worker, they just are not logged any more - */ - async pauseLogging() { - if (this.worker) { - this.worker.postMessage({ command: 'pauseLogging', data: {} }); - } - } - startRecoveryProcess() { if (this.worker) { this.worker.postMessage({ command: 'startRecoveryProcess', data: {} }); diff --git a/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriterWorker.ts b/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriterWorker.ts index 53bdc2a829726..4686a1cf3c860 100644 --- a/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriterWorker.ts +++ b/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriterWorker.ts @@ -103,10 +103,6 @@ if (!isMainThread) { appendMessageSync(data); parentPort?.postMessage({ command, data: true }); break; - case 'pauseLogging': - loggingPaused = true; - clearInterval(fileStatTimer); - break; case 'initialize': const settings: MessageEventBusLogWriterOptions = { logFullBasePath: (data as MessageEventBusLogWriterOptions).logFullBasePath ?? '', diff --git a/packages/cli/src/eventbus/eventBus.controller.ee.ts b/packages/cli/src/eventbus/eventBus.controller.ee.ts deleted file mode 100644 index 980c1e5edbcde..0000000000000 --- a/packages/cli/src/eventbus/eventBus.controller.ee.ts +++ /dev/null @@ -1,138 +0,0 @@ -import express from 'express'; -import type { - MessageEventBusDestinationWebhookOptions, - MessageEventBusDestinationOptions, -} from 'n8n-workflow'; -import { MessageEventBusDestinationTypeNames } from 'n8n-workflow'; - -import { RestController, Get, Post, Delete, GlobalScope, Licensed } from '@/decorators'; -import { AuthenticatedRequest } from '@/requests'; -import { BadRequestError } from '@/errors/response-errors/bad-request.error'; - -import { MessageEventBus } from './MessageEventBus/MessageEventBus'; -import { - isMessageEventBusDestinationSentryOptions, - MessageEventBusDestinationSentry, -} from './MessageEventBusDestination/MessageEventBusDestinationSentry.ee'; -import { - isMessageEventBusDestinationSyslogOptions, - MessageEventBusDestinationSyslog, -} from './MessageEventBusDestination/MessageEventBusDestinationSyslog.ee'; -import { MessageEventBusDestinationWebhook } from './MessageEventBusDestination/MessageEventBusDestinationWebhook.ee'; -import type { MessageEventBusDestination } from './MessageEventBusDestination/MessageEventBusDestination.ee'; - -// ---------------------------------------- -// TypeGuards -// ---------------------------------------- - -const isWithIdString = (candidate: unknown): candidate is { id: string } => { - const o = candidate as { id: string }; - if (!o) return false; - return o.id !== undefined; -}; - -const isMessageEventBusDestinationWebhookOptions = ( - candidate: unknown, -): candidate is MessageEventBusDestinationWebhookOptions => { - const o = candidate as MessageEventBusDestinationWebhookOptions; - if (!o) return false; - return o.url !== undefined; -}; - -const isMessageEventBusDestinationOptions = ( - candidate: unknown, -): candidate is MessageEventBusDestinationOptions => { - const o = candidate as MessageEventBusDestinationOptions; - if (!o) return false; - return o.__type !== undefined; -}; - -// ---------------------------------------- -// Controller -// ---------------------------------------- - -@RestController('/eventbus') -export class EventBusControllerEE { - constructor(private readonly eventBus: MessageEventBus) {} - - // ---------------------------------------- - // Destinations - // ---------------------------------------- - - @Licensed('feat:logStreaming') - @Get('/destination') - @GlobalScope('eventBusDestination:list') - async getDestination(req: express.Request): Promise { - if (isWithIdString(req.query)) { - return await this.eventBus.findDestination(req.query.id); - } else { - return await this.eventBus.findDestination(); - } - } - - @Licensed('feat:logStreaming') - @Post('/destination') - @GlobalScope('eventBusDestination:create') - async postDestination(req: AuthenticatedRequest): Promise { - let result: MessageEventBusDestination | undefined; - if (isMessageEventBusDestinationOptions(req.body)) { - switch (req.body.__type) { - case MessageEventBusDestinationTypeNames.sentry: - if (isMessageEventBusDestinationSentryOptions(req.body)) { - result = await this.eventBus.addDestination( - new MessageEventBusDestinationSentry(this.eventBus, req.body), - ); - } - break; - case MessageEventBusDestinationTypeNames.webhook: - if (isMessageEventBusDestinationWebhookOptions(req.body)) { - result = await this.eventBus.addDestination( - new MessageEventBusDestinationWebhook(this.eventBus, req.body), - ); - } - break; - case MessageEventBusDestinationTypeNames.syslog: - if (isMessageEventBusDestinationSyslogOptions(req.body)) { - result = await this.eventBus.addDestination( - new MessageEventBusDestinationSyslog(this.eventBus, req.body), - ); - } - break; - default: - throw new BadRequestError( - `Body is missing ${req.body.__type} options or type ${req.body.__type} is unknown`, - ); - } - if (result) { - await result.saveToDb(); - return { - ...result.serialize(), - eventBusInstance: undefined, - }; - } - throw new BadRequestError('There was an error adding the destination'); - } - throw new BadRequestError('Body is not configuring MessageEventBusDestinationOptions'); - } - - @Licensed('feat:logStreaming') - @Get('/testmessage') - @GlobalScope('eventBusDestination:test') - async sendTestMessage(req: express.Request): Promise { - if (isWithIdString(req.query)) { - return await this.eventBus.testDestination(req.query.id); - } - return false; - } - - @Licensed('feat:logStreaming') - @Delete('/destination') - @GlobalScope('eventBusDestination:delete') - async deleteDestination(req: AuthenticatedRequest) { - if (isWithIdString(req.query)) { - return await this.eventBus.removeDestination(req.query.id); - } else { - throw new BadRequestError('Query is missing id'); - } - } -} diff --git a/packages/cli/src/eventbus/eventBus.controller.ts b/packages/cli/src/eventbus/eventBus.controller.ts index 3f73227e47b6a..419c4055aa264 100644 --- a/packages/cli/src/eventbus/eventBus.controller.ts +++ b/packages/cli/src/eventbus/eventBus.controller.ts @@ -1,112 +1,132 @@ +import { eventNamesAll } from './EventMessageClasses'; import express from 'express'; -import { EventMessageTypeNames } from 'n8n-workflow'; +import type { + MessageEventBusDestinationWebhookOptions, + MessageEventBusDestinationOptions, +} from 'n8n-workflow'; +import { MessageEventBusDestinationTypeNames } from 'n8n-workflow'; -import { RestController, Get, Post, GlobalScope } from '@/decorators'; +import { RestController, Get, Post, Delete, GlobalScope, Licensed } from '@/decorators'; +import { AuthenticatedRequest } from '@/requests'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; -import { isEventMessageOptions } from './EventMessageClasses/AbstractEventMessage'; -import { EventMessageGeneric } from './EventMessageClasses/EventMessageGeneric'; -import type { EventMessageWorkflowOptions } from './EventMessageClasses/EventMessageWorkflow'; -import { EventMessageWorkflow } from './EventMessageClasses/EventMessageWorkflow'; -import type { EventMessageReturnMode } from './MessageEventBus/MessageEventBus'; import { MessageEventBus } from './MessageEventBus/MessageEventBus'; -import type { EventMessageTypes } from './EventMessageClasses'; -import { eventNamesAll } from './EventMessageClasses'; -import type { EventMessageAuditOptions } from './EventMessageClasses/EventMessageAudit'; -import { EventMessageAudit } from './EventMessageClasses/EventMessageAudit'; -import type { EventMessageNodeOptions } from './EventMessageClasses/EventMessageNode'; -import { EventMessageNode } from './EventMessageClasses/EventMessageNode'; +import { + isMessageEventBusDestinationSentryOptions, + MessageEventBusDestinationSentry, +} from './MessageEventBusDestination/MessageEventBusDestinationSentry.ee'; +import { + isMessageEventBusDestinationSyslogOptions, + MessageEventBusDestinationSyslog, +} from './MessageEventBusDestination/MessageEventBusDestinationSyslog.ee'; +import { MessageEventBusDestinationWebhook } from './MessageEventBusDestination/MessageEventBusDestinationWebhook.ee'; +import type { MessageEventBusDestination } from './MessageEventBusDestination/MessageEventBusDestination.ee'; -// ---------------------------------------- -// TypeGuards -// ---------------------------------------- +const isWithIdString = (candidate: unknown): candidate is { id: string } => { + const o = candidate as { id: string }; + if (!o) return false; + return o.id !== undefined; +}; -const isWithQueryString = (candidate: unknown): candidate is { query: string } => { - const o = candidate as { query: string }; +const isMessageEventBusDestinationWebhookOptions = ( + candidate: unknown, +): candidate is MessageEventBusDestinationWebhookOptions => { + const o = candidate as MessageEventBusDestinationWebhookOptions; if (!o) return false; - return o.query !== undefined; + return o.url !== undefined; }; -// ---------------------------------------- -// Controller -// ---------------------------------------- +const isMessageEventBusDestinationOptions = ( + candidate: unknown, +): candidate is MessageEventBusDestinationOptions => { + const o = candidate as MessageEventBusDestinationOptions; + if (!o) return false; + return o.__type !== undefined; +}; @RestController('/eventbus') export class EventBusController { constructor(private readonly eventBus: MessageEventBus) {} - // ---------------------------------------- - // Events - // ---------------------------------------- - @Get('/event') - @GlobalScope('eventBusEvent:query') - async getEvents( - req: express.Request, - ): Promise> { - if (isWithQueryString(req.query)) { - switch (req.query.query as EventMessageReturnMode) { - case 'sent': - return await this.eventBus.getEventsSent(); - case 'unsent': - return await this.eventBus.getEventsUnsent(); - case 'unfinished': - return await this.eventBus.getUnfinishedExecutions(); - case 'all': - default: - return await this.eventBus.getEventsAll(); - } - } else { - return await this.eventBus.getEventsAll(); - } + @Get('/eventnames') + async getEventNames(): Promise { + return eventNamesAll; } - @Get('/execution/:id') - @GlobalScope('eventBusEvent:read') - async getEventForExecutionId(req: express.Request): Promise { - if (req.params?.id) { - let logHistory; - if (req.query?.logHistory) { - logHistory = parseInt(req.query.logHistory as string, 10); - } - return await this.eventBus.getEventsByExecutionId(req.params.id, logHistory); + @Licensed('feat:logStreaming') + @Get('/destination') + @GlobalScope('eventBusDestination:list') + async getDestination(req: express.Request): Promise { + if (isWithIdString(req.query)) { + return await this.eventBus.findDestination(req.query.id); + } else { + return await this.eventBus.findDestination(); } - return; } - @Post('/event') - @GlobalScope('eventBusEvent:create') - async postEvent(req: express.Request): Promise { - let msg: EventMessageTypes | undefined; - if (isEventMessageOptions(req.body)) { + @Licensed('feat:logStreaming') + @Post('/destination') + @GlobalScope('eventBusDestination:create') + async postDestination(req: AuthenticatedRequest): Promise { + let result: MessageEventBusDestination | undefined; + if (isMessageEventBusDestinationOptions(req.body)) { switch (req.body.__type) { - case EventMessageTypeNames.workflow: - msg = new EventMessageWorkflow(req.body as EventMessageWorkflowOptions); + case MessageEventBusDestinationTypeNames.sentry: + if (isMessageEventBusDestinationSentryOptions(req.body)) { + result = await this.eventBus.addDestination( + new MessageEventBusDestinationSentry(this.eventBus, req.body), + ); + } break; - case EventMessageTypeNames.audit: - msg = new EventMessageAudit(req.body as EventMessageAuditOptions); + case MessageEventBusDestinationTypeNames.webhook: + if (isMessageEventBusDestinationWebhookOptions(req.body)) { + result = await this.eventBus.addDestination( + new MessageEventBusDestinationWebhook(this.eventBus, req.body), + ); + } break; - case EventMessageTypeNames.node: - msg = new EventMessageNode(req.body as EventMessageNodeOptions); + case MessageEventBusDestinationTypeNames.syslog: + if (isMessageEventBusDestinationSyslogOptions(req.body)) { + result = await this.eventBus.addDestination( + new MessageEventBusDestinationSyslog(this.eventBus, req.body), + ); + } break; - case EventMessageTypeNames.generic: default: - msg = new EventMessageGeneric(req.body); + throw new BadRequestError( + `Body is missing ${req.body.__type} options or type ${req.body.__type} is unknown`, + ); } - await this.eventBus.send(msg); - } else { - throw new BadRequestError( - 'Body is not a serialized EventMessage or eventName does not match format {namespace}.{domain}.{event}', - ); + if (result) { + await result.saveToDb(); + return { + ...result.serialize(), + eventBusInstance: undefined, + }; + } + throw new BadRequestError('There was an error adding the destination'); } - return msg; + throw new BadRequestError('Body is not configuring MessageEventBusDestinationOptions'); } - // ---------------------------------------- - // Utilities - // ---------------------------------------- + @Licensed('feat:logStreaming') + @Get('/testmessage') + @GlobalScope('eventBusDestination:test') + async sendTestMessage(req: express.Request): Promise { + if (isWithIdString(req.query)) { + return await this.eventBus.testDestination(req.query.id); + } + return false; + } - @Get('/eventnames') - async getEventNames(): Promise { - return eventNamesAll; + @Licensed('feat:logStreaming') + @Delete('/destination') + @GlobalScope('eventBusDestination:delete') + async deleteDestination(req: AuthenticatedRequest) { + if (isWithIdString(req.query)) { + return await this.eventBus.removeDestination(req.query.id); + } else { + throw new BadRequestError('Query is missing id'); + } } } diff --git a/packages/cli/src/permissions/global-roles.ts b/packages/cli/src/permissions/global-roles.ts index 9824ec1bee062..ad930dfdd21d8 100644 --- a/packages/cli/src/permissions/global-roles.ts +++ b/packages/cli/src/permissions/global-roles.ts @@ -14,12 +14,6 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'communityPackage:uninstall', 'communityPackage:update', 'communityPackage:list', - 'eventBusEvent:create', - 'eventBusEvent:read', - 'eventBusEvent:update', - 'eventBusEvent:delete', - 'eventBusEvent:query', - 'eventBusEvent:create', 'eventBusDestination:create', 'eventBusDestination:read', 'eventBusDestination:update', @@ -81,7 +75,6 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat(); export const GLOBAL_MEMBER_SCOPES: Scope[] = [ - 'eventBusEvent:read', 'eventBusDestination:list', 'eventBusDestination:test', 'tag:create', diff --git a/packages/cli/test/integration/shared/utils/testServer.ts b/packages/cli/test/integration/shared/utils/testServer.ts index 7110366f6568b..4968ddb3d9dab 100644 --- a/packages/cli/test/integration/shared/utils/testServer.ts +++ b/packages/cli/test/integration/shared/utils/testServer.ts @@ -158,9 +158,7 @@ export const setupTestServer = ({ case 'eventBus': const { EventBusController } = await import('@/eventbus/eventBus.controller'); - const { EventBusControllerEE } = await import('@/eventbus/eventBus.controller.ee'); registerController(app, EventBusController); - registerController(app, EventBusControllerEE); break; case 'auth': diff --git a/packages/editor-ui/src/api/eventbus.ee.ts b/packages/editor-ui/src/api/eventbus.ee.ts index 99a8bf480d010..fa99c38e6c36f 100644 --- a/packages/editor-ui/src/api/eventbus.ee.ts +++ b/packages/editor-ui/src/api/eventbus.ee.ts @@ -47,7 +47,3 @@ export async function getDestinationsFromBackend( ): Promise { return await makeRestApiRequest(context, 'GET', '/eventbus/destination'); } - -export async function getExecutionEvents(context: IRestApiContext, executionId: string) { - return await makeRestApiRequest(context, 'GET', `/eventbus/execution/${executionId}`); -} diff --git a/packages/editor-ui/src/stores/rbac.store.ts b/packages/editor-ui/src/stores/rbac.store.ts index caba9e8634e48..a45d0964ae521 100644 --- a/packages/editor-ui/src/stores/rbac.store.ts +++ b/packages/editor-ui/src/stores/rbac.store.ts @@ -24,7 +24,6 @@ export const useRBACStore = defineStore(STORES.RBAC, () => { orchestration: {}, workersView: {}, eventBusDestination: {}, - eventBusEvent: {}, auditLogs: {}, banner: {}, communityPackage: {}, diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index 974b10129d14a..3c6cc81e2609c 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -34,7 +34,6 @@ import type { } from '@/Interface'; import { defineStore } from 'pinia'; import type { - IAbstractEventMessage, IConnection, IConnections, IDataObject, @@ -1432,15 +1431,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { }); } - async function getExecutionEvents(id: string): Promise { - const rootStore = useRootStore(); - return await makeRestApiRequest( - rootStore.getRestApiContext, - 'GET', - `/eventbus/execution/${id}`, - ); - } - function getBinaryUrl( binaryDataId: string, action: 'view' | 'download', @@ -1651,7 +1641,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { fetchExecutionDataById, deleteExecution, addToCurrentExecutions, - getExecutionEvents, getBinaryUrl, setNodePristine, resetChatMessages, From cc4e46eae4ef3d690aa25d97e37dd4de09140781 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 11 Jun 2024 09:11:39 +0200 Subject: [PATCH 16/20] refactor(core): Remove event bus helpers (no-changelog) (#9690) --- .../MessageEventBus/MessageEventBus.ts | 8 +-- .../MessageEventBus/MessageEventBusHelper.ts | 7 --- .../MessageEventBusDestination/Helpers.ee.ts | 52 ------------------- .../MessageEventBusDestination.ee.ts | 6 +++ .../MessageEventBusDestinationSentry.ee.ts | 3 +- .../MessageEventBusDestinationSyslog.ee.ts | 3 +- .../MessageEventBusDestinationWebhook.ee.ts | 3 +- packages/cli/src/eventbus/index.ts | 1 - packages/cli/src/services/metrics.service.ts | 49 +++++++++++++++-- 9 files changed, 59 insertions(+), 73 deletions(-) delete mode 100644 packages/cli/src/eventbus/MessageEventBus/MessageEventBusHelper.ts delete mode 100644 packages/cli/src/eventbus/MessageEventBusDestination/Helpers.ee.ts diff --git a/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts b/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts index 141719c68d69e..3a3e2f282be03 100644 --- a/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts +++ b/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts @@ -22,14 +22,12 @@ import type { EventMessageAuditOptions } from '../EventMessageClasses/EventMessa import { EventMessageAudit } from '../EventMessageClasses/EventMessageAudit'; import type { EventMessageWorkflowOptions } from '../EventMessageClasses/EventMessageWorkflow'; import { EventMessageWorkflow } from '../EventMessageClasses/EventMessageWorkflow'; -import { isLogStreamingEnabled } from './MessageEventBusHelper'; import type { EventMessageNodeOptions } from '../EventMessageClasses/EventMessageNode'; import { EventMessageNode } from '../EventMessageClasses/EventMessageNode'; import { EventMessageGeneric, eventMessageGenericDestinationTestEvent, } from '../EventMessageClasses/EventMessageGeneric'; -import { METRICS_EVENT_NAME } from '../MessageEventBusDestination/Helpers.ee'; import type { AbstractEventMessageOptions } from '../EventMessageClasses/AbstractEventMessageOptions'; import { getEventMessageObjectByType } from '../EventMessageClasses/Helpers'; import { ExecutionRecoveryService } from '../../executions/execution-recovery.service'; @@ -37,6 +35,7 @@ import { EventMessageAiNode, type EventMessageAiNodeOptions, } from '../EventMessageClasses/EventMessageAiNode'; +import { License } from '@/License'; export type EventMessageReturnMode = 'sent' | 'unsent' | 'all' | 'unfinished'; @@ -69,6 +68,7 @@ export class MessageEventBus extends EventEmitter { private readonly workflowRepository: WorkflowRepository, private readonly orchestrationService: OrchestrationService, private readonly recoveryService: ExecutionRecoveryService, + private readonly license: License, ) { super(); } @@ -329,7 +329,7 @@ export class MessageEventBus extends EventEmitter { } private async emitMessage(msg: EventMessageTypes) { - this.emit(METRICS_EVENT_NAME, msg); + this.emit('metrics.messageEventBus.Event', msg); // generic emit for external modules to capture events // this is for internal use ONLY and not for use with custom destinations! @@ -350,7 +350,7 @@ export class MessageEventBus extends EventEmitter { shouldSendMsg(msg: EventMessageTypes): boolean { return ( - isLogStreamingEnabled() && + this.license.isLogStreamingEnabled() && Object.keys(this.destinations).length > 0 && this.hasAnyDestinationSubscribedToEvent(msg) ); diff --git a/packages/cli/src/eventbus/MessageEventBus/MessageEventBusHelper.ts b/packages/cli/src/eventbus/MessageEventBus/MessageEventBusHelper.ts deleted file mode 100644 index 29eab2872aa5e..0000000000000 --- a/packages/cli/src/eventbus/MessageEventBus/MessageEventBusHelper.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { License } from '@/License'; -import { Container } from 'typedi'; - -export function isLogStreamingEnabled(): boolean { - const license = Container.get(License); - return license.isLogStreamingEnabled(); -} diff --git a/packages/cli/src/eventbus/MessageEventBusDestination/Helpers.ee.ts b/packages/cli/src/eventbus/MessageEventBusDestination/Helpers.ee.ts deleted file mode 100644 index 33a6e54bccee4..0000000000000 --- a/packages/cli/src/eventbus/MessageEventBusDestination/Helpers.ee.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { EventMessageTypeNames } from 'n8n-workflow'; -import config from '@/config'; -import type { EventMessageTypes } from '../EventMessageClasses'; - -export const METRICS_EVENT_NAME = 'metrics.messageEventBus.Event'; - -export function getMetricNameForEvent(event: EventMessageTypes): string { - const prefix = config.getEnv('endpoints.metrics.prefix'); - return prefix + event.eventName.replace('n8n.', '').replace(/\./g, '_') + '_total'; -} - -export function getLabelValueForNode(nodeType: string): string { - return nodeType.replace('n8n-nodes-', '').replace(/\./g, '_'); -} - -export function getLabelValueForCredential(credentialType: string): string { - return credentialType.replace(/\./g, '_'); -} - -export function getLabelsForEvent(event: EventMessageTypes): Record { - switch (event.__type) { - case EventMessageTypeNames.audit: - if (event.eventName.startsWith('n8n.audit.user.credentials')) { - return config.getEnv('endpoints.metrics.includeCredentialTypeLabel') - ? { - credential_type: getLabelValueForCredential( - event.payload.credentialType ?? 'unknown', - ), - } - : {}; - } - - if (event.eventName.startsWith('n8n.audit.workflow')) { - return config.getEnv('endpoints.metrics.includeWorkflowIdLabel') - ? { workflow_id: event.payload.workflowId?.toString() ?? 'unknown' } - : {}; - } - break; - - case EventMessageTypeNames.node: - return config.getEnv('endpoints.metrics.includeNodeTypeLabel') - ? { node_type: getLabelValueForNode(event.payload.nodeType ?? 'unknown') } - : {}; - - case EventMessageTypeNames.workflow: - return config.getEnv('endpoints.metrics.includeWorkflowIdLabel') - ? { workflow_id: event.payload.workflowId?.toString() ?? 'unknown' } - : {}; - } - - return {}; -} diff --git a/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestination.ee.ts b/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestination.ee.ts index bf68bb859c7a1..6a7fef6d6e47c 100644 --- a/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestination.ee.ts +++ b/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestination.ee.ts @@ -8,6 +8,7 @@ import type { EventMessageTypes } from '../EventMessageClasses'; import type { EventMessageConfirmSource } from '../EventMessageClasses/EventMessageConfirm'; import type { MessageEventBus, MessageWithCallback } from '../MessageEventBus/MessageEventBus'; import { EventDestinationsRepository } from '@db/repositories/eventDestinations.repository'; +import { License } from '@/License'; export abstract class MessageEventBusDestination implements MessageEventBusDestinationOptions { // Since you can't have static abstract functions - this just serves as a reminder that you need to implement these. Please. @@ -18,6 +19,8 @@ export abstract class MessageEventBusDestination implements MessageEventBusDesti protected readonly logger: Logger; + protected readonly license: License; + __type: MessageEventBusDestinationTypeNames; label: string; @@ -31,7 +34,10 @@ export abstract class MessageEventBusDestination implements MessageEventBusDesti anonymizeAuditMessages: boolean; constructor(eventBusInstance: MessageEventBus, options: MessageEventBusDestinationOptions) { + // @TODO: Use DI this.logger = Container.get(Logger); + this.license = Container.get(License); + this.eventBusInstance = eventBusInstance; this.id = !options.id || options.id.length !== 36 ? uuid() : options.id; this.__type = options.__type ?? MessageEventBusDestinationTypeNames.abstract; diff --git a/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationSentry.ee.ts b/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationSentry.ee.ts index 7c03927aac0e3..25633c3ac628a 100644 --- a/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationSentry.ee.ts +++ b/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationSentry.ee.ts @@ -7,7 +7,6 @@ import type { MessageEventBusDestinationOptions, MessageEventBusDestinationSentryOptions, } from 'n8n-workflow'; -import { isLogStreamingEnabled } from '../MessageEventBus/MessageEventBusHelper'; import { eventMessageGenericDestinationTestEvent } from '../EventMessageClasses/EventMessageGeneric'; import { N8N_VERSION } from '@/constants'; import type { MessageEventBus, MessageWithCallback } from '../MessageEventBus/MessageEventBus'; @@ -57,7 +56,7 @@ export class MessageEventBusDestinationSentry let sendResult = false; if (!this.sentryClient) return sendResult; if (msg.eventName !== eventMessageGenericDestinationTestEvent) { - if (!isLogStreamingEnabled()) return sendResult; + if (!this.license.isLogStreamingEnabled()) return sendResult; if (!this.hasSubscribedToEvent(msg)) return sendResult; } try { diff --git a/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationSyslog.ee.ts b/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationSyslog.ee.ts index ac68d3856a675..f57705319c95c 100644 --- a/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationSyslog.ee.ts +++ b/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationSyslog.ee.ts @@ -7,7 +7,6 @@ import type { } from 'n8n-workflow'; import { MessageEventBusDestinationTypeNames } from 'n8n-workflow'; import { MessageEventBusDestination } from './MessageEventBusDestination.ee'; -import { isLogStreamingEnabled } from '../MessageEventBus/MessageEventBusHelper'; import { eventMessageGenericDestinationTestEvent } from '../EventMessageClasses/EventMessageGeneric'; import type { MessageEventBus, MessageWithCallback } from '../MessageEventBus/MessageEventBus'; import Container from 'typedi'; @@ -73,7 +72,7 @@ export class MessageEventBusDestinationSyslog const { msg, confirmCallback } = emitterPayload; let sendResult = false; if (msg.eventName !== eventMessageGenericDestinationTestEvent) { - if (!isLogStreamingEnabled()) return sendResult; + if (!this.license.isLogStreamingEnabled()) return sendResult; if (!this.hasSubscribedToEvent(msg)) return sendResult; } try { diff --git a/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts b/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts index 6b23c2de78ecb..95e76a854ac1a 100644 --- a/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts +++ b/packages/cli/src/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee.ts @@ -14,7 +14,6 @@ import type { } from 'n8n-workflow'; import { CredentialsHelper } from '@/CredentialsHelper'; import { Agent as HTTPSAgent } from 'https'; -import { isLogStreamingEnabled } from '../MessageEventBus/MessageEventBusHelper'; import { eventMessageGenericDestinationTestEvent } from '../EventMessageClasses/EventMessageGeneric'; import type { MessageEventBus, MessageWithCallback } from '../MessageEventBus/MessageEventBus'; import * as SecretsHelpers from '@/ExternalSecrets/externalSecretsHelper.ee'; @@ -255,7 +254,7 @@ export class MessageEventBusDestinationWebhook const { msg, confirmCallback } = emitterPayload; let sendResult = false; if (msg.eventName !== eventMessageGenericDestinationTestEvent) { - if (!isLogStreamingEnabled()) return sendResult; + if (!this.license.isLogStreamingEnabled()) return sendResult; if (!this.hasSubscribedToEvent(msg)) return sendResult; } // at first run, build this.requestOptions with the destination settings diff --git a/packages/cli/src/eventbus/index.ts b/packages/cli/src/eventbus/index.ts index b9a271bb81f18..fd3658c0a4a13 100644 --- a/packages/cli/src/eventbus/index.ts +++ b/packages/cli/src/eventbus/index.ts @@ -1,3 +1,2 @@ export { EventMessageTypes } from './EventMessageClasses'; export { EventPayloadWorkflow } from './EventMessageClasses/EventMessageWorkflow'; -export { METRICS_EVENT_NAME, getLabelsForEvent } from './MessageEventBusDestination/Helpers.ee'; diff --git a/packages/cli/src/services/metrics.service.ts b/packages/cli/src/services/metrics.service.ts index aae4652e56e77..edee289de169f 100644 --- a/packages/cli/src/services/metrics.service.ts +++ b/packages/cli/src/services/metrics.service.ts @@ -8,9 +8,10 @@ import { Service } from 'typedi'; import EventEmitter from 'events'; import { CacheService } from '@/services/cache/cache.service'; -import { METRICS_EVENT_NAME, getLabelsForEvent, type EventMessageTypes } from '@/eventbus'; +import { type EventMessageTypes } from '@/eventbus'; import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; import { Logger } from '@/Logger'; +import { EventMessageTypeNames } from 'n8n-workflow'; @Service() export class MetricsService extends EventEmitter { @@ -135,7 +136,7 @@ export class MetricsService extends EventEmitter { const counter = new promClient.Counter({ name: metricName, help: `Total number of ${event.eventName} events.`, - labelNames: Object.keys(getLabelsForEvent(event)), + labelNames: Object.keys(this.getLabelsForEvent(event)), }); counter.inc(0); this.counters[event.eventName] = counter; @@ -148,10 +149,52 @@ export class MetricsService extends EventEmitter { if (!config.getEnv('endpoints.metrics.includeMessageEventBusMetrics')) { return; } - this.eventBus.on(METRICS_EVENT_NAME, (event: EventMessageTypes) => { + this.eventBus.on('metrics.messageEventBus.Event', (event: EventMessageTypes) => { const counter = this.getCounterForEvent(event); if (!counter) return; counter.inc(1); }); } + + getLabelsForEvent(event: EventMessageTypes): Record { + switch (event.__type) { + case EventMessageTypeNames.audit: + if (event.eventName.startsWith('n8n.audit.user.credentials')) { + return config.getEnv('endpoints.metrics.includeCredentialTypeLabel') + ? { + credential_type: this.getLabelValueForCredential( + event.payload.credentialType ?? 'unknown', + ), + } + : {}; + } + + if (event.eventName.startsWith('n8n.audit.workflow')) { + return config.getEnv('endpoints.metrics.includeWorkflowIdLabel') + ? { workflow_id: event.payload.workflowId?.toString() ?? 'unknown' } + : {}; + } + break; + + case EventMessageTypeNames.node: + return config.getEnv('endpoints.metrics.includeNodeTypeLabel') + ? { node_type: this.getLabelValueForNode(event.payload.nodeType ?? 'unknown') } + : {}; + + case EventMessageTypeNames.workflow: + return config.getEnv('endpoints.metrics.includeWorkflowIdLabel') + ? { workflow_id: event.payload.workflowId?.toString() ?? 'unknown' } + : {}; + } + + return {}; + } + + getLabelValueForNode(nodeType: string) { + return nodeType.replace('n8n-nodes-', '').replace(/\./g, '_'); + } + + getLabelValueForCredential(credentialType: string) { + return credentialType.replace(/\./g, '_'); + } } From aaa78435b06e56e115a10b473bcc62b19d1a336f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 11 Jun 2024 10:02:23 +0200 Subject: [PATCH 17/20] refactor(core): Remove event bus channel (no-changelog) (#9663) --- packages/cli/src/commands/worker.ts | 2 +- .../eventbus/MessageEventBus/MessageEventBus.ts | 14 -------------- .../main/orchestration.handler.main.service.ts | 7 +------ .../worker/orchestration.worker.service.ts | 6 ------ .../cli/src/services/redis/RedisServiceHelper.ts | 1 - .../services/redis/RedisServicePubSubPublisher.ts | 11 +---------- .../services/redis/RedisServicePubSubSubscriber.ts | 14 +------------- .../test/integration/commands/worker.cmd.test.ts | 11 +++++------ 8 files changed, 9 insertions(+), 57 deletions(-) diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 7182e328502bc..46d7d32315b42 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -273,7 +273,7 @@ export class Worker extends BaseCommand { await this.initOrchestration(); this.logger.debug('Orchestration init complete'); - await Container.get(OrchestrationWorkerService).publishToEventLog( + await Container.get(MessageEventBus).send( new EventMessageGeneric({ eventName: 'n8n.worker.started', payload: { diff --git a/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts b/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts index 3a3e2f282be03..d8f60697a3675 100644 --- a/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts +++ b/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts @@ -3,7 +3,6 @@ import type { DeleteResult } from '@n8n/typeorm'; import { In } from '@n8n/typeorm'; import EventEmitter from 'events'; import uniqby from 'lodash/uniqBy'; -import { jsonParse } from 'n8n-workflow'; import type { MessageEventBusDestinationOptions } from 'n8n-workflow'; import config from '@/config'; @@ -28,8 +27,6 @@ import { EventMessageGeneric, eventMessageGenericDestinationTestEvent, } from '../EventMessageClasses/EventMessageGeneric'; -import type { AbstractEventMessageOptions } from '../EventMessageClasses/AbstractEventMessageOptions'; -import { getEventMessageObjectByType } from '../EventMessageClasses/Helpers'; import { ExecutionRecoveryService } from '../../executions/execution-recovery.service'; import { EventMessageAiNode, @@ -246,17 +243,6 @@ export class MessageEventBus extends EventEmitter { return result; } - async handleRedisEventBusMessage(messageString: string) { - const eventData = jsonParse(messageString); - if (eventData) { - const eventMessage = getEventMessageObjectByType(eventData); - if (eventMessage) { - await this.send(eventMessage); - } - } - return eventData; - } - private async trySendingUnsent(msgs?: EventMessageTypes[]) { const unsentMessages = msgs ?? (await this.getEventsUnsent()); if (unsentMessages.length > 0) { diff --git a/packages/cli/src/services/orchestration/main/orchestration.handler.main.service.ts b/packages/cli/src/services/orchestration/main/orchestration.handler.main.service.ts index 4a57d140d72b6..9206f9db15ab3 100644 --- a/packages/cli/src/services/orchestration/main/orchestration.handler.main.service.ts +++ b/packages/cli/src/services/orchestration/main/orchestration.handler.main.service.ts @@ -1,12 +1,10 @@ -import Container, { Service } from 'typedi'; +import { Service } from 'typedi'; import { COMMAND_REDIS_CHANNEL, - EVENT_BUS_REDIS_CHANNEL, WORKER_RESPONSE_REDIS_CHANNEL, } from '../../redis/RedisServiceHelper'; import { handleWorkerResponseMessageMain } from './handleWorkerResponseMessageMain'; import { handleCommandMessageMain } from './handleCommandMessageMain'; -import { MessageEventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; import { OrchestrationHandlerService } from '../../orchestration.handler.base.service'; @Service() @@ -16,7 +14,6 @@ export class OrchestrationHandlerMainService extends OrchestrationHandlerService await this.redisSubscriber.subscribeToCommandChannel(); await this.redisSubscriber.subscribeToWorkerResponseChannel(); - await this.redisSubscriber.subscribeToEventLog(); this.redisSubscriber.addMessageHandler( 'OrchestrationMessageReceiver', @@ -25,8 +22,6 @@ export class OrchestrationHandlerMainService extends OrchestrationHandlerService await handleWorkerResponseMessageMain(messageString); } else if (channel === COMMAND_REDIS_CHANNEL) { await handleCommandMessageMain(messageString); - } else if (channel === EVENT_BUS_REDIS_CHANNEL) { - await Container.get(MessageEventBus).handleRedisEventBusMessage(messageString); } }, ); diff --git a/packages/cli/src/services/orchestration/worker/orchestration.worker.service.ts b/packages/cli/src/services/orchestration/worker/orchestration.worker.service.ts index 9a00d312b21f2..fc5bb931ea09b 100644 --- a/packages/cli/src/services/orchestration/worker/orchestration.worker.service.ts +++ b/packages/cli/src/services/orchestration/worker/orchestration.worker.service.ts @@ -1,5 +1,4 @@ import { Service } from 'typedi'; -import type { AbstractEventMessage } from '@/eventbus/EventMessageClasses/AbstractEventMessage'; import { OrchestrationService } from '../../orchestration.service'; import config from '@/config'; @@ -12,9 +11,4 @@ export class OrchestrationWorkerService extends OrchestrationService { config.get('generic.instanceType') === 'worker' ); } - - async publishToEventLog(message: AbstractEventMessage) { - if (!this.sanityCheck()) return; - await this.redisPublisher.publishToEventLog(message); - } } diff --git a/packages/cli/src/services/redis/RedisServiceHelper.ts b/packages/cli/src/services/redis/RedisServiceHelper.ts index 257a826b76729..32a72fb22f977 100644 --- a/packages/cli/src/services/redis/RedisServiceHelper.ts +++ b/packages/cli/src/services/redis/RedisServiceHelper.ts @@ -8,7 +8,6 @@ import { Logger } from '@/Logger'; export const EVENT_BUS_REDIS_STREAM = 'n8n:eventstream'; export const COMMAND_REDIS_STREAM = 'n8n:commandstream'; export const WORKER_RESPONSE_REDIS_STREAM = 'n8n:workerstream'; -export const EVENT_BUS_REDIS_CHANNEL = 'n8n.events'; export const COMMAND_REDIS_CHANNEL = 'n8n.commands'; export const WORKER_RESPONSE_REDIS_CHANNEL = 'n8n.worker-response'; export const WORKER_RESPONSE_REDIS_LIST = 'n8n:list:worker-response'; diff --git a/packages/cli/src/services/redis/RedisServicePubSubPublisher.ts b/packages/cli/src/services/redis/RedisServicePubSubPublisher.ts index fe080d8e0f674..b029b6546acc2 100644 --- a/packages/cli/src/services/redis/RedisServicePubSubPublisher.ts +++ b/packages/cli/src/services/redis/RedisServicePubSubPublisher.ts @@ -1,10 +1,5 @@ import { Service } from 'typedi'; -import type { AbstractEventMessage } from '@/eventbus/EventMessageClasses/AbstractEventMessage'; -import { - COMMAND_REDIS_CHANNEL, - EVENT_BUS_REDIS_CHANNEL, - WORKER_RESPONSE_REDIS_CHANNEL, -} from './RedisServiceHelper'; +import { COMMAND_REDIS_CHANNEL, WORKER_RESPONSE_REDIS_CHANNEL } from './RedisServiceHelper'; import type { RedisServiceCommandObject, RedisServiceWorkerResponseObject, @@ -24,10 +19,6 @@ export class RedisServicePubSubPublisher extends RedisServiceBaseSender { await this.redisClient?.publish(channel, message); } - async publishToEventLog(message: AbstractEventMessage): Promise { - await this.publish(EVENT_BUS_REDIS_CHANNEL, message.toString()); - } - async publishToCommandChannel( message: Omit, ): Promise { diff --git a/packages/cli/src/services/redis/RedisServicePubSubSubscriber.ts b/packages/cli/src/services/redis/RedisServicePubSubSubscriber.ts index 79371b6c12be3..a3474c314956a 100644 --- a/packages/cli/src/services/redis/RedisServicePubSubSubscriber.ts +++ b/packages/cli/src/services/redis/RedisServicePubSubSubscriber.ts @@ -1,9 +1,5 @@ import { Service } from 'typedi'; -import { - COMMAND_REDIS_CHANNEL, - EVENT_BUS_REDIS_CHANNEL, - WORKER_RESPONSE_REDIS_CHANNEL, -} from './RedisServiceHelper'; +import { COMMAND_REDIS_CHANNEL, WORKER_RESPONSE_REDIS_CHANNEL } from './RedisServiceHelper'; import { RedisServiceBaseReceiver } from './RedisServiceBaseClasses'; @Service() @@ -44,10 +40,6 @@ export class RedisServicePubSubSubscriber extends RedisServiceBaseReceiver { }); } - async subscribeToEventLog(): Promise { - await this.subscribe(EVENT_BUS_REDIS_CHANNEL); - } - async subscribeToCommandChannel(): Promise { await this.subscribe(COMMAND_REDIS_CHANNEL); } @@ -56,10 +48,6 @@ export class RedisServicePubSubSubscriber extends RedisServiceBaseReceiver { await this.subscribe(WORKER_RESPONSE_REDIS_CHANNEL); } - async unSubscribeFromEventLog(): Promise { - await this.unsubscribe(EVENT_BUS_REDIS_CHANNEL); - } - async unSubscribeFromCommandChannel(): Promise { await this.unsubscribe(COMMAND_REDIS_CHANNEL); } diff --git a/packages/cli/test/integration/commands/worker.cmd.test.ts b/packages/cli/test/integration/commands/worker.cmd.test.ts index 8f26b6a683308..da2a1fd5b255e 100644 --- a/packages/cli/test/integration/commands/worker.cmd.test.ts +++ b/packages/cli/test/integration/commands/worker.cmd.test.ts @@ -15,7 +15,6 @@ import { InternalHooks } from '@/InternalHooks'; import { PostHogClient } from '@/posthog'; import { RedisService } from '@/services/redis.service'; import { OrchestrationHandlerWorkerService } from '@/services/orchestration/worker/orchestration.handler.worker.service'; -import { OrchestrationWorkerService } from '@/services/orchestration/worker/orchestration.worker.service'; import { OrchestrationService } from '@/services/orchestration.service'; import * as testDb from '../shared/testDb'; @@ -23,6 +22,8 @@ import { mockInstance } from '../../shared/mocking'; const oclifConfig = new Config({ root: __dirname }); +let eventBus: MessageEventBus; + beforeAll(async () => { config.set('executions.mode', 'queue'); config.set('binaryDataManager.availableModes', 'filesystem'); @@ -32,7 +33,7 @@ beforeAll(async () => { mockInstance(CacheService); mockInstance(ExternalSecretsManager); mockInstance(BinaryDataService); - mockInstance(MessageEventBus); + eventBus = mockInstance(MessageEventBus); mockInstance(LoadNodesAndCredentials); mockInstance(CredentialTypes); mockInstance(NodeTypes); @@ -58,9 +59,7 @@ test('worker initializes all its components', async () => { jest.spyOn(worker, 'initExternalSecrets').mockImplementation(async () => {}); jest.spyOn(worker, 'initEventBus').mockImplementation(async () => {}); jest.spyOn(worker, 'initOrchestration'); - jest - .spyOn(OrchestrationWorkerService.prototype, 'publishToEventLog') - .mockImplementation(async () => {}); + // jest.spyOn(MessageEventBus.prototype, 'send').mockImplementation(async () => {}); jest .spyOn(OrchestrationHandlerWorkerService.prototype, 'initSubscriber') .mockImplementation(async () => {}); @@ -79,7 +78,7 @@ test('worker initializes all its components', async () => { expect(worker.initEventBus).toHaveBeenCalledTimes(1); expect(worker.initOrchestration).toHaveBeenCalledTimes(1); expect(OrchestrationHandlerWorkerService.prototype.initSubscriber).toHaveBeenCalledTimes(1); - expect(OrchestrationWorkerService.prototype.publishToEventLog).toHaveBeenCalledTimes(1); + expect(eventBus.send).toHaveBeenCalledTimes(1); expect(worker.initQueue).toHaveBeenCalledTimes(1); jest.restoreAllMocks(); From 50bd5b9080213d4286c37b93f598753dbee32eb4 Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Tue, 11 Jun 2024 10:23:30 +0200 Subject: [PATCH 18/20] feat: Update NPS Value Survey (#9638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> --- cypress/e2e/12-canvas-actions.cy.ts | 9 +- cypress/e2e/13-pinning.cy.ts | 7 +- .../1338-ADO-ndv-missing-input-panel.cy.ts | 3 +- cypress/e2e/18-user-management.cy.ts | 19 +- cypress/e2e/19-execution.cy.ts | 15 +- cypress/e2e/2-credentials.cy.ts | 3 +- .../2230-ADO-ndv-reset-data-pagination.cy.ts | 11 +- cypress/e2e/33-settings-personal.cy.ts | 14 +- cypress/e2e/39-import-workflow.cy.ts | 9 +- cypress/e2e/42-nps-survey.cy.ts | 143 +++++++++ cypress/e2e/5-ndv.cy.ts | 3 +- cypress/e2e/6-code-node.cy.ts | 5 +- cypress/e2e/7-workflow-actions.cy.ts | 25 +- cypress/package.json | 1 + cypress/pages/notifications.ts | 17 + cypress/pages/npsSurvey.ts | 16 + cypress/pages/workflow.ts | 12 +- cypress/support/commands.ts | 11 + cypress/support/index.ts | 1 + packages/cli/src/Server.ts | 2 + .../controllers/userSettings.controller.ts | 52 +++ ...1717498465931-AddActivatedAtUserSetting.ts | 20 ++ .../src/databases/migrations/mysqldb/index.ts | 2 + ...1717498465931-AddActivatedAtUserSetting.ts | 18 ++ .../databases/migrations/postgresdb/index.ts | 2 + ...1717498465931-AddActivatedAtUserSetting.ts | 20 ++ .../src/databases/migrations/sqlite/index.ts | 2 + packages/cli/src/requests.ts | 10 + packages/cli/src/services/events.service.ts | 1 + .../userSettings.controller.test.ts | 148 +++++++++ .../design-system/src/css/_tokens.dark.scss | 6 +- packages/design-system/src/css/_tokens.scss | 6 +- packages/editor-ui/src/Interface.ts | 22 +- packages/editor-ui/src/__tests__/utils.ts | 6 - packages/editor-ui/src/api/npsSurvey.ts | 7 + packages/editor-ui/src/api/settings.ts | 18 +- .../src/components/ContactPromptModal.vue | 21 +- .../components/MainHeader/WorkflowDetails.vue | 4 +- packages/editor-ui/src/components/Modal.vue | 3 +- .../editor-ui/src/components/ModalRoot.vue | 4 +- packages/editor-ui/src/components/Modals.vue | 12 +- .../editor-ui/src/components/NpsSurvey.vue | 283 ++++++++++++++++ .../editor-ui/src/components/ValueSurvey.vue | 295 ----------------- .../WorkflowExecutionsInfoAccordion.vue | 7 +- .../workflow/WorkflowExecutionsList.vue | 5 +- .../src/composables/useWorkflowActivate.ts | 6 +- packages/editor-ui/src/constants.ts | 6 +- .../src/plugins/i18n/locales/en.json | 10 + .../editor-ui/src/plugins/telemetry/index.ts | 15 +- .../src/plugins/telemetry/telemetry.types.ts | 2 +- .../src/stores/npsStore.store.spec.ts | 303 ++++++++++++++++++ .../editor-ui/src/stores/npsSurvey.store.ts | 166 ++++++++++ .../editor-ui/src/stores/settings.store.ts | 49 +-- packages/editor-ui/src/stores/ui.store.ts | 15 +- packages/editor-ui/src/stores/users.store.ts | 3 + packages/editor-ui/src/views/NodeView.vue | 8 +- packages/workflow/src/Interfaces.ts | 10 + pnpm-lock.yaml | 20 +- 58 files changed, 1416 insertions(+), 497 deletions(-) create mode 100644 cypress/e2e/42-nps-survey.cy.ts create mode 100644 cypress/pages/notifications.ts create mode 100644 cypress/pages/npsSurvey.ts create mode 100644 packages/cli/src/controllers/userSettings.controller.ts create mode 100644 packages/cli/src/databases/migrations/mysqldb/1717498465931-AddActivatedAtUserSetting.ts create mode 100644 packages/cli/src/databases/migrations/postgresdb/1717498465931-AddActivatedAtUserSetting.ts create mode 100644 packages/cli/src/databases/migrations/sqlite/1717498465931-AddActivatedAtUserSetting.ts create mode 100644 packages/cli/test/unit/controllers/userSettings.controller.test.ts create mode 100644 packages/editor-ui/src/api/npsSurvey.ts create mode 100644 packages/editor-ui/src/components/NpsSurvey.vue delete mode 100644 packages/editor-ui/src/components/ValueSurvey.vue create mode 100644 packages/editor-ui/src/stores/npsStore.store.spec.ts create mode 100644 packages/editor-ui/src/stores/npsSurvey.store.ts diff --git a/cypress/e2e/12-canvas-actions.cy.ts b/cypress/e2e/12-canvas-actions.cy.ts index 4cdfe6808e23a..fae27a545c8af 100644 --- a/cypress/e2e/12-canvas-actions.cy.ts +++ b/cypress/e2e/12-canvas-actions.cy.ts @@ -1,4 +1,5 @@ import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; +import { successToast } from '../pages/notifications'; import { MANUAL_TRIGGER_NODE_NAME, MANUAL_TRIGGER_NODE_DISPLAY_NAME, @@ -166,8 +167,8 @@ describe('Canvas Actions', () => { .findChildByTestId('execute-node-button') .click({ force: true }); WorkflowPage.actions.executeNode(CODE_NODE_NAME); - WorkflowPage.getters.successToast().should('have.length', 2); - WorkflowPage.getters.successToast().should('contain.text', 'Node executed successfully'); + successToast().should('have.length', 2); + successToast().should('contain.text', 'Node executed successfully'); }); it('should disable and enable node', () => { @@ -201,10 +202,10 @@ describe('Canvas Actions', () => { WorkflowPage.actions.selectAll(); WorkflowPage.actions.hitCopy(); - WorkflowPage.getters.successToast().should('contain', 'Copied!'); + successToast().should('contain', 'Copied!'); WorkflowPage.actions.copyNode(CODE_NODE_NAME); - WorkflowPage.getters.successToast().should('contain', 'Copied!'); + successToast().should('contain', 'Copied!'); }); it('should select/deselect all nodes', () => { diff --git a/cypress/e2e/13-pinning.cy.ts b/cypress/e2e/13-pinning.cy.ts index 0929e8cfae8fd..2898adc9caa33 100644 --- a/cypress/e2e/13-pinning.cy.ts +++ b/cypress/e2e/13-pinning.cy.ts @@ -6,6 +6,7 @@ import { BACKEND_BASE_URL, } from '../constants'; import { WorkflowPage, NDV } from '../pages'; +import { errorToast } from '../pages/notifications'; const workflowPage = new WorkflowPage(); const ndv = new NDV(); @@ -139,9 +140,7 @@ describe('Data pinning', () => { test: '1'.repeat(Cypress.env('MAX_PINNED_DATA_SIZE') as number), }, ]); - workflowPage.getters - .errorToast() - .should('contain', 'Workflow has reached the maximum allowed pinned data size'); + errorToast().should('contain', 'Workflow has reached the maximum allowed pinned data size'); }); it('Should show an error when pin data JSON in invalid', () => { @@ -152,7 +151,7 @@ describe('Data pinning', () => { ndv.getters.editPinnedDataButton().should('be.visible'); ndv.actions.setPinnedData('[ { "name": "First item", "code": 2dsa }]'); - workflowPage.getters.errorToast().should('contain', 'Unable to save due to invalid JSON'); + errorToast().should('contain', 'Unable to save due to invalid JSON'); }); it('Should be able to reference paired items in a node located before pinned data', () => { diff --git a/cypress/e2e/1338-ADO-ndv-missing-input-panel.cy.ts b/cypress/e2e/1338-ADO-ndv-missing-input-panel.cy.ts index 046d4d809d1bd..baead21d67efa 100644 --- a/cypress/e2e/1338-ADO-ndv-missing-input-panel.cy.ts +++ b/cypress/e2e/1338-ADO-ndv-missing-input-panel.cy.ts @@ -1,5 +1,6 @@ import { v4 as uuid } from 'uuid'; import { NDV, WorkflowPage as WorkflowPageClass } from '../pages'; +import { successToast } from '../pages/notifications'; const workflowPage = new WorkflowPageClass(); const ndv = new NDV(); @@ -16,7 +17,7 @@ describe('ADO-1338-ndv-missing-input-panel', () => { workflowPage.getters.zoomToFitButton().click(); workflowPage.getters.executeWorkflowButton().click(); // Check success toast (works because Cypress waits enough for the element to show after the http request node has finished) - workflowPage.getters.successToast().should('be.visible'); + successToast().should('be.visible'); workflowPage.actions.openNode('Discourse1'); ndv.getters.inputPanel().should('be.visible'); diff --git a/cypress/e2e/18-user-management.cy.ts b/cypress/e2e/18-user-management.cy.ts index 0332525ab9a29..c9f3cc08cbfb2 100644 --- a/cypress/e2e/18-user-management.cy.ts +++ b/cypress/e2e/18-user-management.cy.ts @@ -1,7 +1,8 @@ import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN } from '../constants'; -import { MainSidebar, SettingsSidebar, SettingsUsersPage, WorkflowPage } from '../pages'; +import { MainSidebar, SettingsSidebar, SettingsUsersPage } from '../pages'; import { PersonalSettingsPage } from '../pages/settings-personal'; import { getVisibleSelect } from '../utils'; +import { errorToast, successToast } from '../pages/notifications'; /** * User A - Instance owner @@ -24,7 +25,6 @@ const updatedPersonalData = { }; const usersSettingsPage = new SettingsUsersPage(); -const workflowPage = new WorkflowPage(); const personalSettingsPage = new PersonalSettingsPage(); const settingsSidebar = new SettingsSidebar(); const mainSidebar = new MainSidebar(); @@ -174,7 +174,7 @@ describe('User Management', { disableAutoLogin: true }, () => { usersSettingsPage.getters.deleteDataRadioButton().click(); usersSettingsPage.getters.deleteDataInput().type('delete all data'); usersSettingsPage.getters.deleteUserButton().click(); - workflowPage.getters.successToast().should('contain', 'User deleted'); + successToast().should('contain', 'User deleted'); }); it('should delete user and transfer their data', () => { @@ -184,7 +184,7 @@ describe('User Management', { disableAutoLogin: true }, () => { usersSettingsPage.getters.userSelectDropDown().click(); usersSettingsPage.getters.userSelectOptions().first().click(); usersSettingsPage.getters.deleteUserButton().click(); - workflowPage.getters.successToast().should('contain', 'User deleted'); + successToast().should('contain', 'User deleted'); }); it('should allow user to change their personal data', () => { @@ -196,7 +196,7 @@ describe('User Management', { disableAutoLogin: true }, () => { personalSettingsPage.getters .currentUserName() .should('contain', `${updatedPersonalData.newFirstName} ${updatedPersonalData.newLastName}`); - workflowPage.getters.successToast().should('contain', 'Personal details updated'); + successToast().should('contain', 'Personal details updated'); }); it("shouldn't allow user to set weak password", () => { @@ -211,10 +211,7 @@ describe('User Management', { disableAutoLogin: true }, () => { personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password); personalSettingsPage.getters.changePasswordLink().click(); personalSettingsPage.actions.updatePassword('iCannotRemember', updatedPersonalData.newPassword); - workflowPage.getters - .errorToast() - .closest('div') - .should('contain', 'Provided current password is incorrect.'); + errorToast().closest('div').should('contain', 'Provided current password is incorrect.'); }); it('should change current user password', () => { @@ -224,7 +221,7 @@ describe('User Management', { disableAutoLogin: true }, () => { INSTANCE_OWNER.password, updatedPersonalData.newPassword, ); - workflowPage.getters.successToast().should('contain', 'Password updated'); + successToast().should('contain', 'Password updated'); personalSettingsPage.actions.loginWithNewData( INSTANCE_OWNER.email, updatedPersonalData.newPassword, @@ -248,7 +245,7 @@ describe('User Management', { disableAutoLogin: true }, () => { updatedPersonalData.newPassword, ); personalSettingsPage.actions.updateEmail(updatedPersonalData.newEmail); - workflowPage.getters.successToast().should('contain', 'Personal details updated'); + successToast().should('contain', 'Personal details updated'); personalSettingsPage.actions.loginWithNewData( updatedPersonalData.newEmail, updatedPersonalData.newPassword, diff --git a/cypress/e2e/19-execution.cy.ts b/cypress/e2e/19-execution.cy.ts index 921dc8bbca500..1bf01536b21e8 100644 --- a/cypress/e2e/19-execution.cy.ts +++ b/cypress/e2e/19-execution.cy.ts @@ -1,6 +1,7 @@ import { v4 as uuid } from 'uuid'; import { NDV, WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages'; import { SCHEDULE_TRIGGER_NODE_NAME, EDIT_FIELDS_SET_NODE_NAME } from '../constants'; +import { errorToast, successToast } from '../pages/notifications'; const workflowPage = new WorkflowPageClass(); const executionsTab = new WorkflowExecutionsTab(); @@ -68,7 +69,7 @@ describe('Execution', () => { workflowPage.getters.clearExecutionDataButton().should('not.exist'); // Check success toast (works because Cypress waits enough for the element to show after the http request node has finished) - workflowPage.getters.successToast().should('be.visible'); + successToast().should('be.visible'); }); it('should test manual workflow stop', () => { @@ -127,7 +128,7 @@ describe('Execution', () => { workflowPage.getters.clearExecutionDataButton().should('not.exist'); // Check success toast (works because Cypress waits enough for the element to show after the http request node has finished) - workflowPage.getters.successToast().should('be.visible'); + successToast().should('be.visible'); }); it('should test webhook workflow', () => { @@ -200,7 +201,7 @@ describe('Execution', () => { workflowPage.getters.clearExecutionDataButton().should('not.exist'); // Check success toast (works because Cypress waits enough for the element to show after the http request node has finished) - workflowPage.getters.successToast().should('be.visible'); + successToast().should('be.visible'); }); it('should test webhook workflow stop', () => { @@ -274,7 +275,7 @@ describe('Execution', () => { workflowPage.getters.clearExecutionDataButton().should('not.exist'); // Check success toast (works because Cypress waits enough for the element to show after the http request node has finished) - workflowPage.getters.successToast().should('be.visible'); + successToast().should('be.visible'); }); describe('execution preview', () => { @@ -286,7 +287,7 @@ describe('Execution', () => { executionsTab.actions.deleteExecutionInPreview(); executionsTab.getters.successfulExecutionListItems().should('have.length', 0); - workflowPage.getters.successToast().contains('Execution deleted'); + successToast().contains('Execution deleted'); }); }); @@ -587,7 +588,7 @@ describe('Execution', () => { cy.wait('@workflowRun'); // Wait again for the websocket message to arrive and the UI to update. cy.wait(100); - workflowPage.getters.errorToast({ timeout: 1 }).should('not.exist'); + errorToast({ timeout: 1 }).should('not.exist'); }); it('should execute workflow partially up to the node that has issues', () => { @@ -614,6 +615,6 @@ describe('Execution', () => { .within(() => cy.get('.fa-check')) .should('exist'); - workflowPage.getters.errorToast().should('contain', 'Problem in node ‘Telegram‘'); + errorToast().should('contain', 'Problem in node ‘Telegram‘'); }); }); diff --git a/cypress/e2e/2-credentials.cy.ts b/cypress/e2e/2-credentials.cy.ts index cc10513ba709b..a9f710bf7c3e2 100644 --- a/cypress/e2e/2-credentials.cy.ts +++ b/cypress/e2e/2-credentials.cy.ts @@ -12,6 +12,7 @@ import { TRELLO_NODE_NAME, } from '../constants'; import { CredentialsModal, CredentialsPage, NDV, WorkflowPage } from '../pages'; +import { successToast } from '../pages/notifications'; import { getVisibleSelect } from '../utils'; const credentialsPage = new CredentialsPage(); @@ -153,7 +154,7 @@ describe('Credentials', () => { credentialsModal.getters.credentialsEditModal().should('be.visible'); credentialsModal.getters.deleteButton().click(); cy.get('.el-message-box').find('button').contains('Yes').click(); - workflowPage.getters.successToast().contains('Credential deleted'); + successToast().contains('Credential deleted'); workflowPage.getters .nodeCredentialsSelect() .find('input') diff --git a/cypress/e2e/2230-ADO-ndv-reset-data-pagination.cy.ts b/cypress/e2e/2230-ADO-ndv-reset-data-pagination.cy.ts index 849b520c4c792..995423bc3a40e 100644 --- a/cypress/e2e/2230-ADO-ndv-reset-data-pagination.cy.ts +++ b/cypress/e2e/2230-ADO-ndv-reset-data-pagination.cy.ts @@ -1,4 +1,5 @@ import { NDV, WorkflowPage } from '../pages'; +import { clearNotifications } from '../pages/notifications'; const workflowPage = new WorkflowPage(); const ndv = new NDV(); @@ -12,10 +13,7 @@ describe('ADO-2230 NDV Pagination Reset', () => { // execute node outputting 10 pages, check output of first page ndv.actions.execute(); - workflowPage.getters - .successToast() - .find('.el-notification__closeBtn') - .click({ multiple: true }); + clearNotifications(); ndv.getters.outputTbodyCell(1, 1).invoke('text').should('eq', 'Terry.Dach@hotmail.com'); // open 4th page, check output @@ -27,10 +25,7 @@ describe('ADO-2230 NDV Pagination Reset', () => { // output a lot less data ndv.getters.parameterInput('randomDataCount').find('input').clear().type('20'); ndv.actions.execute(); - workflowPage.getters - .successToast() - .find('.el-notification__closeBtn') - .click({ multiple: true }); + clearNotifications(); // check we are back to second page now ndv.getters.pagination().find('li.number').should('have.length', 2); diff --git a/cypress/e2e/33-settings-personal.cy.ts b/cypress/e2e/33-settings-personal.cy.ts index 58e0f05237869..73dc7476b8ae0 100644 --- a/cypress/e2e/33-settings-personal.cy.ts +++ b/cypress/e2e/33-settings-personal.cy.ts @@ -1,6 +1,4 @@ -import { WorkflowPage } from '../pages'; - -const workflowPage = new WorkflowPage(); +import { errorToast, successToast } from '../pages/notifications'; const INVALID_NAMES = [ 'https://n8n.io', @@ -33,8 +31,8 @@ describe('Personal Settings', () => { cy.getByTestId('personal-data-form').find('input[name="firstName"]').clear().type(name[0]); cy.getByTestId('personal-data-form').find('input[name="lastName"]').clear().type(name[1]); cy.getByTestId('save-settings-button').click(); - workflowPage.getters.successToast().should('contain', 'Personal details updated'); - workflowPage.getters.successToast().find('.el-notification__closeBtn').click(); + successToast().should('contain', 'Personal details updated'); + successToast().find('.el-notification__closeBtn').click(); }); }); it('not allow malicious values for personal data', () => { @@ -43,10 +41,8 @@ describe('Personal Settings', () => { cy.getByTestId('personal-data-form').find('input[name="firstName"]').clear().type(name); cy.getByTestId('personal-data-form').find('input[name="lastName"]').clear().type(name); cy.getByTestId('save-settings-button').click(); - workflowPage.getters - .errorToast() - .should('contain', 'Malicious firstName | Malicious lastName'); - workflowPage.getters.errorToast().find('.el-notification__closeBtn').click(); + errorToast().should('contain', 'Malicious firstName | Malicious lastName'); + errorToast().find('.el-notification__closeBtn').click(); }); }); }); diff --git a/cypress/e2e/39-import-workflow.cy.ts b/cypress/e2e/39-import-workflow.cy.ts index 5b78b7c65d097..f92790eb3b61b 100644 --- a/cypress/e2e/39-import-workflow.cy.ts +++ b/cypress/e2e/39-import-workflow.cy.ts @@ -1,5 +1,6 @@ import { WorkflowPage } from '../pages'; import { MessageBox as MessageBoxClass } from '../pages/modals/message-box'; +import { errorToast, successToast } from '../pages/notifications'; const workflowPage = new WorkflowPage(); const messageBox = new MessageBoxClass(); @@ -29,9 +30,9 @@ describe('Import workflow', () => { workflowPage.getters.canvasNodes().should('have.length', 4); - workflowPage.getters.errorToast().should('not.exist'); + errorToast().should('not.exist'); - workflowPage.getters.successToast().should('not.exist'); + successToast().should('not.exist'); }); it('clicking outside modal should not show error toast', () => { @@ -42,7 +43,7 @@ describe('Import workflow', () => { cy.get('body').click(0, 0); - workflowPage.getters.errorToast().should('not.exist'); + errorToast().should('not.exist'); }); it('canceling modal should not show error toast', () => { @@ -52,7 +53,7 @@ describe('Import workflow', () => { workflowPage.getters.workflowMenuItemImportFromURLItem().click(); messageBox.getters.cancel().click(); - workflowPage.getters.errorToast().should('not.exist'); + errorToast().should('not.exist'); }); }); diff --git a/cypress/e2e/42-nps-survey.cy.ts b/cypress/e2e/42-nps-survey.cy.ts new file mode 100644 index 0000000000000..e06fe43ba8050 --- /dev/null +++ b/cypress/e2e/42-nps-survey.cy.ts @@ -0,0 +1,143 @@ +import { INSTANCE_ADMIN } from '../constants'; +import { clearNotifications } from '../pages/notifications'; +import { + getNpsSurvey, + getNpsSurveyClose, + getNpsSurveyEmail, + getNpsSurveyRatings, +} from '../pages/npsSurvey'; +import { WorkflowPage } from '../pages/workflow'; + +const workflowPage = new WorkflowPage(); + +const NOW = 1717771477012; +const ONE_DAY = 24 * 60 * 60 * 1000; +const THREE_DAYS = ONE_DAY * 3; +const SEVEN_DAYS = ONE_DAY * 7; +const ABOUT_SIX_MONTHS = ONE_DAY * 30 * 6 + ONE_DAY; + +describe('NpsSurvey', () => { + beforeEach(() => { + cy.resetDatabase(); + cy.signin(INSTANCE_ADMIN); + }); + + it('shows nps survey to recently activated user and can submit email ', () => { + cy.intercept('/rest/settings', { middleware: true }, (req) => { + req.on('response', (res) => { + if (res.body.data) { + res.body.data.telemetry = { + enabled: true, + config: { + key: 'test', + url: 'https://telemetry-test.n8n.io', + }, + }; + } + }); + }); + + cy.intercept('/rest/login', { middleware: true }, (req) => { + req.on('response', (res) => { + if (res.body.data) { + res.body.data.settings = res.body.data.settings || {}; + res.body.data.settings.userActivated = true; + res.body.data.settings.userActivatedAt = NOW - THREE_DAYS - 1000; + } + }); + }); + + workflowPage.actions.visit(true, NOW); + + workflowPage.actions.saveWorkflowOnButtonClick(); + getNpsSurvey().should('be.visible'); + getNpsSurveyRatings().find('button').should('have.length', 11); + getNpsSurveyRatings().find('button').first().click(); + + getNpsSurveyEmail().find('input').type('test@n8n.io'); + getNpsSurveyEmail().find('button').click(); + + // test that modal does not show up again until 6 months later + workflowPage.actions.visit(true, NOW + ONE_DAY); + workflowPage.actions.saveWorkflowOnButtonClick(); + getNpsSurvey().should('not.be.visible'); + + // 6 months later + workflowPage.actions.visit(true, NOW + ABOUT_SIX_MONTHS); + workflowPage.actions.saveWorkflowOnButtonClick(); + getNpsSurvey().should('be.visible'); + }); + + it('allows user to ignore survey 3 times before stopping to show until 6 months later', () => { + cy.intercept('/rest/settings', { middleware: true }, (req) => { + req.on('response', (res) => { + if (res.body.data) { + res.body.data.telemetry = { + enabled: true, + config: { + key: 'test', + url: 'https://telemetry-test.n8n.io', + }, + }; + } + }); + }); + + cy.intercept('/rest/login', { middleware: true }, (req) => { + req.on('response', (res) => { + if (res.body.data) { + res.body.data.settings = res.body.data.settings || {}; + res.body.data.settings.userActivated = true; + res.body.data.settings.userActivatedAt = NOW - THREE_DAYS - 1000; + } + }); + }); + + // can ignore survey and it won't show up again + workflowPage.actions.visit(true, NOW); + workflowPage.actions.saveWorkflowOnButtonClick(); + clearNotifications(); + + getNpsSurvey().should('be.visible'); + getNpsSurveyClose().click(); + getNpsSurvey().should('not.be.visible'); + + workflowPage.actions.visit(true, NOW + ONE_DAY); + workflowPage.actions.saveWorkflowOnButtonClick(); + getNpsSurvey().should('not.be.visible'); + + // shows up seven days later to ignore again + workflowPage.actions.visit(true, NOW + SEVEN_DAYS + 10000); + workflowPage.actions.saveWorkflowOnButtonClick(); + clearNotifications(); + getNpsSurvey().should('be.visible'); + getNpsSurveyClose().click(); + getNpsSurvey().should('not.be.visible'); + + workflowPage.actions.visit(true, NOW + SEVEN_DAYS + 10000); + workflowPage.actions.saveWorkflowOnButtonClick(); + getNpsSurvey().should('not.be.visible'); + + // shows up after at least seven days later to ignore again + workflowPage.actions.visit(true, NOW + (SEVEN_DAYS + 10000) * 2 + ONE_DAY); + workflowPage.actions.saveWorkflowOnButtonClick(); + clearNotifications(); + getNpsSurvey().should('be.visible'); + getNpsSurveyClose().click(); + getNpsSurvey().should('not.be.visible'); + + workflowPage.actions.visit(true, NOW + (SEVEN_DAYS + 10000) * 2 + ONE_DAY * 2); + workflowPage.actions.saveWorkflowOnButtonClick(); + getNpsSurvey().should('not.be.visible'); + + // does not show up again after at least 7 days + workflowPage.actions.visit(true, NOW + (SEVEN_DAYS + 10000) * 3 + ONE_DAY * 3); + workflowPage.actions.saveWorkflowOnButtonClick(); + getNpsSurvey().should('not.be.visible'); + + // shows up 6 months later + workflowPage.actions.visit(true, NOW + (SEVEN_DAYS + 10000) * 3 + ABOUT_SIX_MONTHS); + workflowPage.actions.saveWorkflowOnButtonClick(); + getNpsSurvey().should('be.visible'); + }); +}); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 7623e2de979cb..4b86bdbb20ca1 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -5,6 +5,7 @@ import { NDV, WorkflowPage } from '../pages'; import { NodeCreator } from '../pages/features/node-creator'; import { clickCreateNewCredential } from '../composables/ndv'; import { setCredentialValues } from '../composables/modals/credential-modal'; +import { successToast } from '../pages/notifications'; const workflowPage = new WorkflowPage(); const ndv = new NDV(); @@ -734,7 +735,7 @@ describe('NDV', () => { ndv.getters.triggerPanelExecuteButton().realClick(); cy.wait('@workflowRun').then(() => { ndv.getters.triggerPanelExecuteButton().should('contain', 'Test step'); - workflowPage.getters.successToast().should('exist'); + successToast().should('exist'); }); }); diff --git a/cypress/e2e/6-code-node.cy.ts b/cypress/e2e/6-code-node.cy.ts index 0964cff41e225..4c6379cfd69c0 100644 --- a/cypress/e2e/6-code-node.cy.ts +++ b/cypress/e2e/6-code-node.cy.ts @@ -1,5 +1,6 @@ import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; import { NDV } from '../pages/ndv'; +import { successToast } from '../pages/notifications'; const WorkflowPage = new WorkflowPageClass(); const ndv = new NDV(); @@ -28,13 +29,13 @@ describe('Code node', () => { it('should execute the placeholder successfully in both modes', () => { ndv.actions.execute(); - WorkflowPage.getters.successToast().contains('Node executed successfully'); + successToast().contains('Node executed successfully'); ndv.getters.parameterInput('mode').click(); ndv.actions.selectOptionInParameterDropdown('mode', 'Run Once for Each Item'); ndv.actions.execute(); - WorkflowPage.getters.successToast().contains('Node executed successfully'); + successToast().contains('Node executed successfully'); }); }); diff --git a/cypress/e2e/7-workflow-actions.cy.ts b/cypress/e2e/7-workflow-actions.cy.ts index 362b86ee8971c..713d02c41118b 100644 --- a/cypress/e2e/7-workflow-actions.cy.ts +++ b/cypress/e2e/7-workflow-actions.cy.ts @@ -12,6 +12,7 @@ import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows'; import { getVisibleSelect } from '../utils'; import { WorkflowExecutionsTab } from '../pages'; +import { errorToast, successToast } from '../pages/notifications'; const NEW_WORKFLOW_NAME = 'Something else'; const DUPLICATE_WORKFLOW_NAME = 'Duplicated workflow'; @@ -36,7 +37,7 @@ describe('Workflow Actions', () => { WorkflowPage.getters.isWorkflowSaved(); }); - it('should not save already saved workflow', () => { + it.skip('should not save already saved workflow', () => { cy.intercept('PATCH', '/rest/workflows/*').as('saveWorkflow'); WorkflowPage.actions.saveWorkflowOnButtonClick(); WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); @@ -72,19 +73,19 @@ describe('Workflow Actions', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME); WorkflowPage.actions.saveWorkflowOnButtonClick(); - WorkflowPage.getters.successToast().should('exist'); + successToast().should('exist'); WorkflowPage.actions.clickWorkflowActivator(); - WorkflowPage.getters.errorToast().should('exist'); + errorToast().should('exist'); }); it('should be be able to activate workflow when nodes with errors are disabled', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME); WorkflowPage.actions.saveWorkflowOnButtonClick(); - WorkflowPage.getters.successToast().should('exist'); + successToast().should('exist'); // First, try to activate the workflow with errors WorkflowPage.actions.clickWorkflowActivator(); - WorkflowPage.getters.errorToast().should('exist'); + errorToast().should('exist'); // Now, disable the node with errors WorkflowPage.getters.canvasNodes().last().click(); WorkflowPage.actions.hitDisableNodeShortcut(); @@ -174,7 +175,7 @@ describe('Workflow Actions', () => { cy.get('body').type(META_KEY, { delay: 500, release: false }).type('a'); cy.get('.jtk-drag-selected').should('have.length', 2); cy.get('body').type(META_KEY, { delay: 500, release: false }).type('c'); - WorkflowPage.getters.successToast().should('exist'); + successToast().should('exist'); }); it('should paste nodes (both current and old node versions)', () => { @@ -239,7 +240,7 @@ describe('Workflow Actions', () => { // Save settings WorkflowPage.getters.workflowSettingsSaveButton().click(); WorkflowPage.getters.workflowSettingsModal().should('not.exist'); - WorkflowPage.getters.successToast().should('exist'); + successToast().should('exist'); }); }).as('loadWorkflows'); }); @@ -257,7 +258,7 @@ describe('Workflow Actions', () => { WorkflowPage.getters.workflowMenuItemDelete().click(); cy.get('div[role=dialog][aria-modal=true]').should('be.visible'); cy.get('button.btn--confirm').should('be.visible').click(); - WorkflowPage.getters.successToast().should('exist'); + successToast().should('exist'); cy.url().should('include', WorkflowPages.url); }); @@ -286,7 +287,7 @@ describe('Workflow Actions', () => { .contains('Duplicate') .should('be.visible'); WorkflowPage.getters.duplicateWorkflowModal().find('button').contains('Duplicate').click(); - WorkflowPage.getters.errorToast().should('not.exist'); + errorToast().should('not.exist'); } beforeEach(() => { @@ -331,14 +332,14 @@ describe('Workflow Actions', () => { WorkflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.saveWorkflowOnButtonClick(); WorkflowPage.getters.executeWorkflowButton().click(); - WorkflowPage.getters.successToast().should('contain.text', 'Workflow executed successfully'); + successToast().should('contain.text', 'Workflow executed successfully'); }); it('should run workflow using keyboard shortcut', () => { WorkflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.saveWorkflowOnButtonClick(); cy.get('body').type(META_KEY, { delay: 500, release: false }).type('{enter}'); - WorkflowPage.getters.successToast().should('contain.text', 'Workflow executed successfully'); + successToast().should('contain.text', 'Workflow executed successfully'); }); it('should not run empty workflows', () => { @@ -350,7 +351,7 @@ describe('Workflow Actions', () => { WorkflowPage.getters.executeWorkflowButton().should('be.disabled'); // Keyboard shortcut should not work cy.get('body').type(META_KEY, { delay: 500, release: false }).type('{enter}'); - WorkflowPage.getters.successToast().should('not.exist'); + successToast().should('not.exist'); }); }); diff --git a/cypress/package.json b/cypress/package.json index aabcc929c5bd5..ffc6404e37639 100644 --- a/cypress/package.json +++ b/cypress/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@ngneat/falso": "^6.4.0", + "@sinonjs/fake-timers": "^11.2.2", "cross-env": "^7.0.3", "cypress": "^13.6.2", "cypress-otp": "^1.0.3", diff --git a/cypress/pages/notifications.ts b/cypress/pages/notifications.ts new file mode 100644 index 0000000000000..162c5360070cf --- /dev/null +++ b/cypress/pages/notifications.ts @@ -0,0 +1,17 @@ +type CyGetOptions = Parameters<(typeof cy)['get']>[1]; + +/** + * Getters + */ +export const successToast = () => cy.get('.el-notification:has(.el-notification--success)'); +export const warningToast = () => cy.get('.el-notification:has(.el-notification--warning)'); +export const errorToast = (options?: CyGetOptions) => + cy.get('.el-notification:has(.el-notification--error)', options); +export const infoToast = () => cy.get('.el-notification:has(.el-notification--info)'); + +/** + * Actions + */ +export const clearNotifications = () => { + successToast().find('.el-notification__closeBtn').click({ multiple: true }); +}; diff --git a/cypress/pages/npsSurvey.ts b/cypress/pages/npsSurvey.ts new file mode 100644 index 0000000000000..b68d33797d62e --- /dev/null +++ b/cypress/pages/npsSurvey.ts @@ -0,0 +1,16 @@ +/** + * Getters + */ + +export const getNpsSurvey = () => cy.getByTestId('nps-survey-modal'); + +export const getNpsSurveyRatings = () => cy.getByTestId('nps-survey-ratings'); + +export const getNpsSurveyEmail = () => cy.getByTestId('nps-survey-email'); + +export const getNpsSurveyClose = () => + cy.getByTestId('nps-survey-modal').find('button.el-drawer__close-btn'); + +/** + * Actions + */ diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index 2a03c80c51c6d..234da9c9e557e 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -3,8 +3,6 @@ import { getVisibleSelect } from '../utils'; import { BasePage } from './base'; import { NodeCreator } from './features/node-creator'; -type CyGetOptions = Parameters<(typeof cy)['get']>[1]; - const nodeCreator = new NodeCreator(); export class WorkflowPage extends BasePage { url = '/workflow/new'; @@ -49,11 +47,6 @@ export class WorkflowPage extends BasePage { canvasNodePlusEndpointByName: (nodeName: string, index = 0) => { return cy.get(this.getters.getEndpointSelector('plus', nodeName, index)); }, - successToast: () => cy.get('.el-notification:has(.el-notification--success)'), - warningToast: () => cy.get('.el-notification:has(.el-notification--warning)'), - errorToast: (options?: CyGetOptions) => - cy.get('.el-notification:has(.el-notification--error)', options), - infoToast: () => cy.get('.el-notification:has(.el-notification--info)'), activatorSwitch: () => cy.getByTestId('workflow-activate-switch'), workflowMenu: () => cy.getByTestId('workflow-menu'), firstStepButton: () => cy.getByTestId('canvas-add-button'), @@ -137,8 +130,11 @@ export class WorkflowPage extends BasePage { }; actions = { - visit: (preventNodeViewUnload = true) => { + visit: (preventNodeViewUnload = true, appDate?: number) => { cy.visit(this.url); + if (appDate) { + cy.setAppDate(appDate); + } cy.waitForLoad(); cy.window().then((win) => { win.preventNodeViewBeforeUnload = preventNodeViewUnload; diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 3bb28e2df8053..dec7d79f5ea29 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,4 +1,5 @@ import 'cypress-real-events'; +import FakeTimers from '@sinonjs/fake-timers'; import { WorkflowPage } from '../pages'; import { BACKEND_BASE_URL, @@ -8,6 +9,16 @@ import { N8N_AUTH_COOKIE, } from '../constants'; +Cypress.Commands.add('setAppDate', (targetDate: number | Date) => { + cy.window().then((win) => { + FakeTimers.withGlobal(win).install({ + now: targetDate, + toFake: ['Date'], + shouldAdvanceTime: true, + }); + }); +}); + Cypress.Commands.add('getByTestId', (selector, ...args) => { return cy.get(`[data-test-id="${selector}"]`, ...args); }); diff --git a/cypress/support/index.ts b/cypress/support/index.ts index f25104f883d8d..247dc5745ecbc 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -54,6 +54,7 @@ declare global { } >; resetDatabase(): void; + setAppDate(targetDate: number | Date): void; } } } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index c6e30d76b8b81..93ab6141b95f7 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -71,6 +71,7 @@ import { InvitationController } from './controllers/invitation.controller'; import { OrchestrationService } from '@/services/orchestration.service'; import { ProjectController } from './controllers/project.controller'; import { RoleController } from './controllers/role.controller'; +import { UserSettingsController } from './controllers/userSettings.controller'; const exec = promisify(callbackExec); @@ -148,6 +149,7 @@ export class Server extends AbstractServer { ProjectController, RoleController, CurlController, + UserSettingsController, ]; if ( diff --git a/packages/cli/src/controllers/userSettings.controller.ts b/packages/cli/src/controllers/userSettings.controller.ts new file mode 100644 index 0000000000000..aff1415710b8f --- /dev/null +++ b/packages/cli/src/controllers/userSettings.controller.ts @@ -0,0 +1,52 @@ +import { Patch, RestController } from '@/decorators'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { NpsSurveyRequest } from '@/requests'; +import { UserService } from '@/services/user.service'; +import type { NpsSurveyState } from 'n8n-workflow'; + +function getNpsSurveyState(state: unknown): NpsSurveyState | undefined { + if (typeof state !== 'object' || state === null) { + return; + } + if (!('lastShownAt' in state) || typeof state.lastShownAt !== 'number') { + return; + } + if ('responded' in state && state.responded === true) { + return { + responded: true, + lastShownAt: state.lastShownAt, + }; + } + + if ( + 'waitingForResponse' in state && + state.waitingForResponse === true && + 'ignoredCount' in state && + typeof state.ignoredCount === 'number' + ) { + return { + waitingForResponse: true, + ignoredCount: state.ignoredCount, + lastShownAt: state.lastShownAt, + }; + } + + return; +} + +@RestController('/user-settings') +export class UserSettingsController { + constructor(private readonly userService: UserService) {} + + @Patch('/nps-survey') + async updateNpsSurvey(req: NpsSurveyRequest.NpsSurveyUpdate): Promise { + const state = getNpsSurveyState(req.body); + if (!state) { + throw new BadRequestError('Invalid nps survey state structure'); + } + + await this.userService.updateSettings(req.user.id, { + npsSurvey: state, + }); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/1717498465931-AddActivatedAtUserSetting.ts b/packages/cli/src/databases/migrations/mysqldb/1717498465931-AddActivatedAtUserSetting.ts new file mode 100644 index 0000000000000..e7adda5d94c1a --- /dev/null +++ b/packages/cli/src/databases/migrations/mysqldb/1717498465931-AddActivatedAtUserSetting.ts @@ -0,0 +1,20 @@ +import type { MigrationContext, ReversibleMigration } from '@db/types'; + +export class AddActivatedAtUserSetting1717498465931 implements ReversibleMigration { + async up({ queryRunner, escape }: MigrationContext) { + const now = Date.now(); + await queryRunner.query( + `UPDATE ${escape.tableName('user')} + SET settings = JSON_SET(COALESCE(settings, '{}'), '$.userActivatedAt', CAST('${now}' AS JSON)) + WHERE settings IS NOT NULL AND JSON_EXTRACT(settings, '$.userActivated') = true`, + ); + } + + async down({ queryRunner, escape }: MigrationContext) { + await queryRunner.query( + `UPDATE ${escape.tableName('user')} + SET settings = JSON_REMOVE(CAST(settings AS JSON), '$.userActivatedAt') + WHERE settings IS NOT NULL`, + ); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index 8b467999f52ae..51c514dca5240 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -57,6 +57,7 @@ import { RemoveFailedExecutionStatus1711018413374 } from '../common/171101841337 import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase'; import { RemoveNodesAccess1712044305787 } from '../common/1712044305787-RemoveNodesAccess'; import { MakeExecutionStatusNonNullable1714133768521 } from '../common/1714133768521-MakeExecutionStatusNonNullable'; +import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting'; export const mysqlMigrations: Migration[] = [ InitialMigration1588157391238, @@ -117,4 +118,5 @@ export const mysqlMigrations: Migration[] = [ RemoveNodesAccess1712044305787, CreateProject1714133768519, MakeExecutionStatusNonNullable1714133768521, + AddActivatedAtUserSetting1717498465931, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/1717498465931-AddActivatedAtUserSetting.ts b/packages/cli/src/databases/migrations/postgresdb/1717498465931-AddActivatedAtUserSetting.ts new file mode 100644 index 0000000000000..471206282fd90 --- /dev/null +++ b/packages/cli/src/databases/migrations/postgresdb/1717498465931-AddActivatedAtUserSetting.ts @@ -0,0 +1,18 @@ +import type { MigrationContext, ReversibleMigration } from '@db/types'; + +export class AddActivatedAtUserSetting1717498465931 implements ReversibleMigration { + async up({ queryRunner, escape }: MigrationContext) { + const now = Date.now(); + await queryRunner.query( + `UPDATE ${escape.tableName('user')} + SET settings = jsonb_set(COALESCE(settings::jsonb, '{}'), '{userActivatedAt}', to_jsonb(${now})) + WHERE settings IS NOT NULL AND (settings->>'userActivated')::boolean = true`, + ); + } + + async down({ queryRunner, escape }: MigrationContext) { + await queryRunner.query( + `UPDATE ${escape.tableName('user')} SET settings = settings::jsonb - 'userActivatedAt' WHERE settings IS NOT NULL`, + ); + } +} diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index 6ca797c1da97f..dc2b14edff355 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -56,6 +56,7 @@ import { RemoveFailedExecutionStatus1711018413374 } from '../common/171101841337 import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase'; import { RemoveNodesAccess1712044305787 } from '../common/1712044305787-RemoveNodesAccess'; import { MakeExecutionStatusNonNullable1714133768521 } from '../common/1714133768521-MakeExecutionStatusNonNullable'; +import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting'; export const postgresMigrations: Migration[] = [ InitialMigration1587669153312, @@ -115,4 +116,5 @@ export const postgresMigrations: Migration[] = [ RemoveNodesAccess1712044305787, CreateProject1714133768519, MakeExecutionStatusNonNullable1714133768521, + AddActivatedAtUserSetting1717498465931, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1717498465931-AddActivatedAtUserSetting.ts b/packages/cli/src/databases/migrations/sqlite/1717498465931-AddActivatedAtUserSetting.ts new file mode 100644 index 0000000000000..552f5db725b4e --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1717498465931-AddActivatedAtUserSetting.ts @@ -0,0 +1,20 @@ +import type { MigrationContext, ReversibleMigration } from '@/databases/types'; + +export class AddActivatedAtUserSetting1717498465931 implements ReversibleMigration { + transaction = false as const; + + async up({ queryRunner, escape }: MigrationContext) { + const now = Date.now(); + await queryRunner.query( + `UPDATE ${escape.tableName('user')} + SET settings = JSON_SET(settings, '$.userActivatedAt', ${now}) + WHERE JSON_EXTRACT(settings, '$.userActivated') = true;`, + ); + } + + async down({ queryRunner, escape }: MigrationContext) { + await queryRunner.query( + `UPDATE ${escape.tableName('user')} SET settings = JSON_REMOVE(settings, '$.userActivatedAt')`, + ); + } +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index aefd1649b46e1..6bda48f6f4d37 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -54,6 +54,7 @@ import { RemoveFailedExecutionStatus1711018413374 } from '../common/171101841337 import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase'; import { RemoveNodesAccess1712044305787 } from '../common/1712044305787-RemoveNodesAccess'; import { MakeExecutionStatusNonNullable1714133768521 } from '../common/1714133768521-MakeExecutionStatusNonNullable'; +import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting'; const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, @@ -111,6 +112,7 @@ const sqliteMigrations: Migration[] = [ RemoveNodesAccess1712044305787, CreateProject1714133768519, MakeExecutionStatusNonNullable1714133768521, + AddActivatedAtUserSetting1717498465931, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 378afa3648b62..a73efe003121c 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -601,3 +601,13 @@ export declare namespace ProjectRequest { >; type Delete = AuthenticatedRequest<{ projectId: string }, {}, {}, { transferId?: string }>; } + +// ---------------------------------- +// /nps-survey +// ---------------------------------- +export declare namespace NpsSurveyRequest { + // can be refactored to + // type NpsSurveyUpdate = AuthenticatedRequest<{}, {}, NpsSurveyState>; + // once some schema validation is added + type NpsSurveyUpdate = AuthenticatedRequest<{}, {}, unknown>; +} diff --git a/packages/cli/src/services/events.service.ts b/packages/cli/src/services/events.service.ts index 8017597e41ef5..7f5ad4c121b28 100644 --- a/packages/cli/src/services/events.service.ts +++ b/packages/cli/src/services/events.service.ts @@ -63,6 +63,7 @@ export class EventsService extends EventEmitter { await Container.get(UserService).updateSettings(owner.id, { firstSuccessfulWorkflowId: workflowId, userActivated: true, + userActivatedAt: runData.startedAt.getTime(), }); } diff --git a/packages/cli/test/unit/controllers/userSettings.controller.test.ts b/packages/cli/test/unit/controllers/userSettings.controller.test.ts new file mode 100644 index 0000000000000..e29afb74ccc29 --- /dev/null +++ b/packages/cli/test/unit/controllers/userSettings.controller.test.ts @@ -0,0 +1,148 @@ +import { UserSettingsController } from '@/controllers/userSettings.controller'; +import type { NpsSurveyRequest } from '@/requests'; +import type { UserService } from '@/services/user.service'; +import { mock } from 'jest-mock-extended'; +import type { NpsSurveyState } from 'n8n-workflow'; + +const NOW = 1717607016208; +jest.useFakeTimers({ + now: NOW, +}); + +describe('UserSettingsController', () => { + const userService = mock(); + const controller = new UserSettingsController(userService); + + describe('NPS Survey', () => { + test.each([ + [ + 'updates user settings, setting response state to done', + { + responded: true, + lastShownAt: 1717607016208, + }, + [], + ], + [ + 'updates user settings, setting response state to done, ignoring other keys like waitForResponse', + { + responded: true, + lastShownAt: 1717607016208, + waitingForResponse: true, + }, + ['waitingForResponse'], + ], + [ + 'updates user settings, setting response state to done, ignoring other keys like ignoredCount', + { + responded: true, + lastShownAt: 1717607016208, + ignoredCount: 1, + }, + ['ignoredCount'], + ], + [ + 'updates user settings, setting response state to done, ignoring other unknown keys', + { + responded: true, + lastShownAt: 1717607016208, + x: 1, + }, + ['x'], + ], + [ + 'updates user settings, updating ignore count', + { + waitingForResponse: true, + lastShownAt: 1717607016208, + ignoredCount: 1, + }, + [], + ], + [ + 'updates user settings, reseting to waiting state', + { + waitingForResponse: true, + ignoredCount: 0, + lastShownAt: 1717607016208, + }, + [], + ], + [ + 'updates user settings, updating ignore count, ignoring unknown keys', + { + waitingForResponse: true, + lastShownAt: 1717607016208, + ignoredCount: 1, + x: 1, + }, + ['x'], + ], + ])('%s', async (_, toUpdate, toIgnore: string[] | undefined) => { + const req = mock(); + req.user.id = '1'; + req.body = toUpdate; + await controller.updateNpsSurvey(req); + + const npsSurvey = Object.keys(toUpdate).reduce( + (accu, key) => { + if ((toIgnore ?? []).includes(key)) { + return accu; + } + accu[key] = (toUpdate as Record)[key]; + return accu; + }, + {} as Record, + ); + expect(userService.updateSettings).toHaveBeenCalledWith('1', { npsSurvey }); + }); + + it('updates user settings, setting response state to done', async () => { + const req = mock(); + req.user.id = '1'; + + const npsSurvey: NpsSurveyState = { + responded: true, + lastShownAt: 1717607016208, + }; + req.body = npsSurvey; + + await controller.updateNpsSurvey(req); + + expect(userService.updateSettings).toHaveBeenCalledWith('1', { npsSurvey }); + }); + + it('updates user settings, updating ignore count', async () => { + const req = mock(); + req.user.id = '1'; + + const npsSurvey: NpsSurveyState = { + waitingForResponse: true, + lastShownAt: 1717607016208, + ignoredCount: 1, + }; + req.body = npsSurvey; + + await controller.updateNpsSurvey(req); + + expect(userService.updateSettings).toHaveBeenCalledWith('1', { npsSurvey }); + }); + + test.each([ + ['is missing', {}], + ['is undefined', undefined], + ['is responded but missing lastShownAt', { responded: true }], + ['is waitingForResponse but missing lastShownAt', { waitingForResponse: true }], + [ + 'is waitingForResponse but missing ignoredCount', + { lastShownAt: 123, waitingForResponse: true }, + ], + ])('thows error when request payload is %s', async (_, payload) => { + const req = mock(); + req.user.id = '1'; + req.body = payload; + + await expect(controller.updateNpsSurvey(req)).rejects.toThrowError(); + }); + }); +}); diff --git a/packages/design-system/src/css/_tokens.dark.scss b/packages/design-system/src/css/_tokens.dark.scss index 5aa56c2677f86..732416956a15a 100644 --- a/packages/design-system/src/css/_tokens.dark.scss +++ b/packages/design-system/src/css/_tokens.dark.scss @@ -217,9 +217,9 @@ // Avatar --color-avatar-font: var(--prim-gray-0); - // Value Survey - --color-value-survey-background: var(--prim-gray-740); - --color-value-survey-font: var(--prim-gray-0); + // NPS Survey + --color-nps-survey-background: var(--prim-gray-740); + --color-nps-survey-font: var(--prim-gray-0); // Switch (Activation, boolean) --color-switch-background: var(--prim-gray-820); diff --git a/packages/design-system/src/css/_tokens.scss b/packages/design-system/src/css/_tokens.scss index ed8b5ac490306..e7314f46b522a 100644 --- a/packages/design-system/src/css/_tokens.scss +++ b/packages/design-system/src/css/_tokens.scss @@ -278,9 +278,9 @@ // Avatar --color-avatar-font: var(--color-text-xlight); - // Value Survey - --color-value-survey-background: var(--prim-gray-740); - --color-value-survey-font: var(--prim-gray-0); + // NPS Survey + --color-nps-survey-background: var(--prim-gray-740); + --color-nps-survey-font: var(--prim-gray-0); // Action Dropdown --color-action-dropdown-item-active-background: var(--color-background-base); diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 2d2c37559af3e..4616080c23693 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -742,18 +742,9 @@ export interface IUserListAction { } export interface IN8nPrompts { - message: string; - title: string; - showContactPrompt: boolean; - showValueSurvey: boolean; -} - -export interface IN8nValueSurveyData { - [key: string]: string; -} - -export interface IN8nPromptResponse { - updated: boolean; + message?: string; + title?: string; + showContactPrompt?: boolean; } export const enum UserManagementAuthenticationMethod { @@ -1214,6 +1205,8 @@ export type Modals = { [key: string]: ModalState; }; +export type ModalKey = keyof Modals; + export type ModalState = { open: boolean; mode?: string | null; @@ -1366,7 +1359,6 @@ export interface INodeCreatorState { export interface ISettingsState { initialized: boolean; settings: IN8nUISettings; - promptsData: IN8nPrompts; userManagement: IUserManagementSettings; templatesEndpointHealthy: boolean; api: { @@ -1931,3 +1923,7 @@ export type EnterpriseEditionFeatureKey = | 'AdvancedPermissions'; export type EnterpriseEditionFeatureValue = keyof Omit; + +export interface IN8nPromptResponse { + updated: boolean; +} diff --git a/packages/editor-ui/src/__tests__/utils.ts b/packages/editor-ui/src/__tests__/utils.ts index d7ff2e3586487..7f6e14e24fab1 100644 --- a/packages/editor-ui/src/__tests__/utils.ts +++ b/packages/editor-ui/src/__tests__/utils.ts @@ -39,12 +39,6 @@ export const waitAllPromises = async () => await new Promise((resolve) => setTim export const SETTINGS_STORE_DEFAULT_STATE: ISettingsState = { initialized: true, settings: defaultSettings, - promptsData: { - message: '', - title: '', - showContactPrompt: false, - showValueSurvey: false, - }, userManagement: { showSetupOnFirstLoad: false, smtpSetup: false, diff --git a/packages/editor-ui/src/api/npsSurvey.ts b/packages/editor-ui/src/api/npsSurvey.ts new file mode 100644 index 0000000000000..3d235851bc36d --- /dev/null +++ b/packages/editor-ui/src/api/npsSurvey.ts @@ -0,0 +1,7 @@ +import type { IRestApiContext } from '@/Interface'; +import { makeRestApiRequest } from '@/utils/apiUtils'; +import type { NpsSurveyState } from 'n8n-workflow'; + +export async function updateNpsSurveyState(context: IRestApiContext, state: NpsSurveyState) { + await makeRestApiRequest(context, 'PATCH', '/user-settings/nps-survey', state); +} diff --git a/packages/editor-ui/src/api/settings.ts b/packages/editor-ui/src/api/settings.ts index bfd2e9c0b3cc4..ffac562da9a08 100644 --- a/packages/editor-ui/src/api/settings.ts +++ b/packages/editor-ui/src/api/settings.ts @@ -1,9 +1,4 @@ -import type { - IRestApiContext, - IN8nPrompts, - IN8nValueSurveyData, - IN8nPromptResponse, -} from '../Interface'; +import type { IRestApiContext, IN8nPrompts, IN8nPromptResponse } from '../Interface'; import { makeRestApiRequest, get, post } from '@/utils/apiUtils'; import { N8N_IO_BASE_URL, NPM_COMMUNITY_NODE_SEARCH_API_URL } from '@/constants'; import type { IN8nUISettings } from 'n8n-workflow'; @@ -34,17 +29,6 @@ export async function submitContactInfo( ); } -export async function submitValueSurvey( - instanceId: string, - userId: string, - params: IN8nValueSurveyData, -): Promise { - return await post(N8N_IO_BASE_URL, '/value-survey', params, { - 'n8n-instance-id': instanceId, - 'n8n-user-id': userId, - }); -} - export async function getAvailableCommunityPackageCount(): Promise { const response = await get( NPM_COMMUNITY_NODE_SEARCH_API_URL, diff --git a/packages/editor-ui/src/components/ContactPromptModal.vue b/packages/editor-ui/src/components/ContactPromptModal.vue index dcf1e97598090..8bb2110d34099 100644 --- a/packages/editor-ui/src/components/ContactPromptModal.vue +++ b/packages/editor-ui/src/components/ContactPromptModal.vue @@ -33,20 +33,27 @@ + + + + diff --git a/packages/editor-ui/src/components/ValueSurvey.vue b/packages/editor-ui/src/components/ValueSurvey.vue deleted file mode 100644 index b925500f51cd5..0000000000000 --- a/packages/editor-ui/src/components/ValueSurvey.vue +++ /dev/null @@ -1,295 +0,0 @@ - - - - - diff --git a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsInfoAccordion.vue b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsInfoAccordion.vue index bc6b708423190..0ce91f32ac834 100644 --- a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsInfoAccordion.vue +++ b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsInfoAccordion.vue @@ -47,6 +47,7 @@ import { PLACEHOLDER_EMPTY_WORKFLOW_ID, WORKFLOW_SETTINGS_MODAL_KEY } from '@/co import type { IWorkflowSettings } from 'n8n-workflow'; import { deepCopy } from 'n8n-workflow'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; +import { useNpsSurveyStore } from '@/stores/npsSurvey.store'; interface IWorkflowSaveSettings { saveFailedExecutions: boolean; @@ -85,7 +86,7 @@ export default defineComponent({ }; }, computed: { - ...mapStores(useRootStore, useSettingsStore, useUIStore, useWorkflowsStore), + ...mapStores(useRootStore, useSettingsStore, useUIStore, useWorkflowsStore, useNpsSurveyStore), accordionItems(): object[] { return [ { @@ -228,7 +229,9 @@ export default defineComponent({ name: this.workflowName, tags: this.currentWorkflowTagIds, }); - if (saved) await this.settingsStore.fetchPromptsData(); + if (saved) { + await this.npsSurveyStore.fetchPromptsData(); + } }, }, }); diff --git a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue index 6d6885dd5b5c8..eee9370a9aafb 100644 --- a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue +++ b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue @@ -49,6 +49,7 @@ import { useTagsStore } from '@/stores/tags.store'; import { executionFilterToQueryFilter } from '@/utils/executionUtils'; import { useExternalHooks } from '@/composables/useExternalHooks'; import { useDebounce } from '@/composables/useDebounce'; +import { useNpsSurveyStore } from '@/stores/npsSurvey.store'; export default defineComponent({ name: 'WorkflowExecutionsList', @@ -79,7 +80,7 @@ export default defineComponent({ if (confirmModal === MODAL_CONFIRM) { const saved = await this.workflowHelpers.saveCurrentWorkflow({}, false); if (saved) { - await this.settingsStore.fetchPromptsData(); + await this.npsSurveyStore.fetchPromptsData(); } this.uiStore.stateIsDirty = false; next(); @@ -141,7 +142,7 @@ export default defineComponent({ }; }, computed: { - ...mapStores(useTagsStore, useNodeTypesStore, useSettingsStore, useUIStore), + ...mapStores(useTagsStore, useNodeTypesStore, useSettingsStore, useUIStore, useNpsSurveyStore), temporaryExecution(): ExecutionSummary | undefined { const isTemporary = !this.executions.find((execution) => execution.id === this.execution?.id); return isTemporary ? this.execution : undefined; diff --git a/packages/editor-ui/src/composables/useWorkflowActivate.ts b/packages/editor-ui/src/composables/useWorkflowActivate.ts index 1514416c305c3..96204e089b359 100644 --- a/packages/editor-ui/src/composables/useWorkflowActivate.ts +++ b/packages/editor-ui/src/composables/useWorkflowActivate.ts @@ -6,7 +6,6 @@ import { WORKFLOW_ACTIVE_MODAL_KEY, } from '@/constants'; import { useUIStore } from '@/stores/ui.store'; -import { useSettingsStore } from '@/stores/settings.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useExternalHooks } from '@/composables/useExternalHooks'; import { useRouter } from 'vue-router'; @@ -15,6 +14,7 @@ import { useTelemetry } from '@/composables/useTelemetry'; import { useToast } from '@/composables/useToast'; import { useI18n } from '@/composables/useI18n'; import { ref } from 'vue'; +import { useNpsSurveyStore } from '@/stores/npsSurvey.store'; export function useWorkflowActivate() { const updatingWorkflowActivation = ref(false); @@ -22,11 +22,11 @@ export function useWorkflowActivate() { const router = useRouter(); const workflowHelpers = useWorkflowHelpers({ router }); const workflowsStore = useWorkflowsStore(); - const settingsStore = useSettingsStore(); const uiStore = useUIStore(); const telemetry = useTelemetry(); const toast = useToast(); const i18n = useI18n(); + const npsSurveyStore = useNpsSurveyStore(); //methods @@ -117,7 +117,7 @@ export function useWorkflowActivate() { if (newActiveState && useStorage(LOCAL_STORAGE_ACTIVATION_FLAG).value !== 'true') { uiStore.openModal(WORKFLOW_ACTIVE_MODAL_KEY); } else { - await settingsStore.fetchPromptsData(); + await npsSurveyStore.fetchPromptsData(); } } }; diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 67cb3e7b94ef6..c0a7c439064d0 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -50,7 +50,7 @@ export const WORKFLOW_LM_CHAT_MODAL_KEY = 'lmChat'; export const WORKFLOW_SHARE_MODAL_KEY = 'workflowShare'; export const PERSONALIZATION_MODAL_KEY = 'personalization'; export const CONTACT_PROMPT_MODAL_KEY = 'contactPrompt'; -export const VALUE_SURVEY_MODAL_KEY = 'valueSurvey'; +export const NPS_SURVEY_MODAL_KEY = 'npsSurvey'; export const WORKFLOW_ACTIVE_MODAL_KEY = 'activation'; export const ONBOARDING_CALL_SIGNUP_MODAL_KEY = 'onboardingCallSignup'; export const COMMUNITY_PACKAGE_INSTALL_MODAL_KEY = 'communityPackageInstall'; @@ -767,6 +767,10 @@ export const TIME = { DAY: 24 * 60 * 60 * 1000, }; +export const THREE_DAYS_IN_MILLIS = 3 * TIME.DAY; +export const SEVEN_DAYS_IN_MILLIS = 7 * TIME.DAY; +export const SIX_MONTHS_IN_MILLIS = 6 * 30 * TIME.DAY; + /** * Mouse button codes */ diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 1495915890e9e..30e234d55c416 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -1449,6 +1449,16 @@ "pushConnection.executionError": "There was a problem executing the workflow{error}", "pushConnection.executionError.openNode": " Open node", "pushConnection.executionError.details": "
{details}", + "prompts.productTeamMessage": "Our product team will get in touch personally", + "prompts.npsSurvey.recommendationQuestion": "How likely are you to recommend n8n to a friend or colleague?", + "prompts.npsSurvey.greatFeedbackTitle": "Great to hear! Can we reach out to see how we can make n8n even better for you?", + "prompts.npsSurvey.defaultFeedbackTitle": "Thanks for your feedback! We'd love to understand how we can improve. Can we reach out?", + "prompts.npsSurvey.notLikely": "Not likely", + "prompts.npsSurvey.veryLikely": "Very likely", + "prompts.npsSurvey.send": "Send", + "prompts.npsSurvey.yourEmailAddress": "Your email address", + "prompts.npsSurvey.reviewUs": "If you’d like to help even more, leave us a review on G2.", + "prompts.npsSurvey.thanks": "Thanks for your feedback", "resourceLocator.id.placeholder": "Enter ID...", "resourceLocator.mode.id": "By ID", "resourceLocator.mode.url": "By URL", diff --git a/packages/editor-ui/src/plugins/telemetry/index.ts b/packages/editor-ui/src/plugins/telemetry/index.ts index 60aa37b12816d..0e97eadef78a0 100644 --- a/packages/editor-ui/src/plugins/telemetry/index.ts +++ b/packages/editor-ui/src/plugins/telemetry/index.ts @@ -3,7 +3,7 @@ import type { ITelemetrySettings, ITelemetryTrackProperties, IDataObject } from import type { RouteLocation } from 'vue-router'; import type { INodeCreateElement, IUpdateInformation } from '@/Interface'; -import type { IUserNodesPanelSession } from './telemetry.types'; +import type { IUserNodesPanelSession, RudderStack } from './telemetry.types'; import { APPEND_ATTRIBUTION_DEFAULT_PATH, MICROSOFT_TEAMS_NODE_TYPE, @@ -22,7 +22,7 @@ export class Telemetry { private previousPath: string; - private get rudderStack() { + private get rudderStack(): RudderStack | undefined { return window.rudderanalytics; } @@ -92,12 +92,12 @@ export class Telemetry { traits.user_cloud_id = settingsStore.settings?.n8nMetadata?.userId ?? ''; } if (userId) { - this.rudderStack.identify( + this.rudderStack?.identify( `${instanceId}#${userId}${projectId ? '#' + projectId : ''}`, traits, ); } else { - this.rudderStack.reset(); + this.rudderStack?.reset(); } } @@ -282,6 +282,9 @@ export class Telemetry { private initRudderStack(key: string, url: string, options: IDataObject) { window.rudderanalytics = window.rudderanalytics || []; + if (!this.rudderStack) { + return; + } this.rudderStack.methods = [ 'load', @@ -298,6 +301,10 @@ export class Telemetry { this.rudderStack.factory = (method: string) => { return (...args: unknown[]) => { + if (!this.rudderStack) { + throw new Error('RudderStack not initialized'); + } + const argsCopy = [method, ...args]; this.rudderStack.push(argsCopy); diff --git a/packages/editor-ui/src/plugins/telemetry/telemetry.types.ts b/packages/editor-ui/src/plugins/telemetry/telemetry.types.ts index 4e4e083c7431f..9f774d1670717 100644 --- a/packages/editor-ui/src/plugins/telemetry/telemetry.types.ts +++ b/packages/editor-ui/src/plugins/telemetry/telemetry.types.ts @@ -19,7 +19,7 @@ interface IUserNodesPanelSessionData { * Simplified version of: * https://github.com/rudderlabs/rudder-sdk-js/blob/master/dist/rudder-sdk-js/index.d.ts */ -interface RudderStack extends Array { +export interface RudderStack extends Array { [key: string]: unknown; methods: string[]; diff --git a/packages/editor-ui/src/stores/npsStore.store.spec.ts b/packages/editor-ui/src/stores/npsStore.store.spec.ts new file mode 100644 index 0000000000000..141109cc285ba --- /dev/null +++ b/packages/editor-ui/src/stores/npsStore.store.spec.ts @@ -0,0 +1,303 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { useNpsSurveyStore } from './npsSurvey.store'; +import { THREE_DAYS_IN_MILLIS, TIME, NPS_SURVEY_MODAL_KEY } from '@/constants'; +import { useSettingsStore } from './settings.store'; + +const { openModal, updateNpsSurveyState } = vi.hoisted(() => { + return { + openModal: vi.fn(), + updateNpsSurveyState: vi.fn(), + }; +}); + +vi.mock('@/stores/ui.store', () => ({ + useUIStore: vi.fn(() => ({ + openModal, + })), +})); + +vi.mock('@/api/npsSurvey', () => ({ + updateNpsSurveyState, +})); + +const NOW = 1717602004819; + +vi.useFakeTimers({ + now: NOW, +}); + +describe('useNpsSurvey', () => { + let npsSurveyStore: ReturnType; + + beforeEach(() => { + vi.restoreAllMocks(); + setActivePinia(createPinia()); + useSettingsStore().settings.telemetry = { enabled: true }; + npsSurveyStore = useNpsSurveyStore(); + }); + + it('by default, without login, does not show survey', async () => { + await npsSurveyStore.showNpsSurveyIfPossible(); + + expect(openModal).not.toHaveBeenCalled(); + expect(updateNpsSurveyState).not.toHaveBeenCalled(); + }); + + it('does not show nps survey if user activated less than 3 days ago', async () => { + npsSurveyStore.setupNpsSurveyOnLogin('1', { + userActivated: true, + userActivatedAt: NOW - THREE_DAYS_IN_MILLIS + 10000, + }); + + await npsSurveyStore.showNpsSurveyIfPossible(); + + expect(openModal).not.toHaveBeenCalled(); + expect(updateNpsSurveyState).not.toHaveBeenCalled(); + }); + + it('shows nps survey if user activated more than 3 days ago and has yet to see survey', async () => { + npsSurveyStore.setupNpsSurveyOnLogin('1', { + userActivated: true, + userActivatedAt: NOW - THREE_DAYS_IN_MILLIS - 10000, + }); + + await npsSurveyStore.showNpsSurveyIfPossible(); + + expect(openModal).toHaveBeenCalledWith(NPS_SURVEY_MODAL_KEY); + expect(updateNpsSurveyState).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: '/rest', + }), + { + ignoredCount: 0, + lastShownAt: NOW, + waitingForResponse: true, + }, + ); + }); + + it('does not show nps survey if user has seen and responded to survey less than 6 months ago', async () => { + npsSurveyStore.setupNpsSurveyOnLogin('1', { + userActivated: true, + userActivatedAt: NOW - 10 * TIME.DAY, + npsSurvey: { + responded: true, + lastShownAt: NOW - 2 * TIME.DAY, + }, + }); + + await npsSurveyStore.showNpsSurveyIfPossible(); + + expect(openModal).not.toHaveBeenCalled(); + expect(updateNpsSurveyState).not.toHaveBeenCalledWith(); + }); + + it('does not show nps survey if user has responded survey more than 7 days ago', async () => { + npsSurveyStore.setupNpsSurveyOnLogin('1', { + userActivated: true, + userActivatedAt: NOW - 10 * TIME.DAY, + npsSurvey: { + responded: true, + lastShownAt: NOW - 8 * TIME.DAY, + }, + }); + + await npsSurveyStore.showNpsSurveyIfPossible(); + + expect(openModal).not.toHaveBeenCalled(); + expect(updateNpsSurveyState).not.toHaveBeenCalled(); + }); + + it('shows nps survey if user has responded survey more than 6 months ago', async () => { + npsSurveyStore.setupNpsSurveyOnLogin('1', { + userActivated: true, + userActivatedAt: NOW - 30 * 7 * TIME.DAY, + npsSurvey: { + responded: true, + lastShownAt: NOW - (30 * 6 + 1) * TIME.DAY, + }, + }); + + await npsSurveyStore.showNpsSurveyIfPossible(); + + expect(openModal).toHaveBeenCalledWith(NPS_SURVEY_MODAL_KEY); + expect(updateNpsSurveyState).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: '/rest', + }), + { + ignoredCount: 0, + lastShownAt: NOW, + waitingForResponse: true, + }, + ); + }); + + it('does not show nps survey if user has ignored survey less than 7 days ago', async () => { + npsSurveyStore.setupNpsSurveyOnLogin('1', { + userActivated: true, + userActivatedAt: NOW - 10 * TIME.DAY, + npsSurvey: { + waitingForResponse: true, + lastShownAt: NOW - 5 * TIME.DAY, + ignoredCount: 0, + }, + }); + + await npsSurveyStore.showNpsSurveyIfPossible(); + + expect(openModal).not.toHaveBeenCalled(); + expect(updateNpsSurveyState).not.toHaveBeenCalled(); + }); + + it('shows nps survey if user has ignored survey more than 7 days ago', async () => { + npsSurveyStore.setupNpsSurveyOnLogin('1', { + userActivated: true, + userActivatedAt: NOW - 10 * TIME.DAY, + npsSurvey: { + waitingForResponse: true, + lastShownAt: NOW - 8 * TIME.DAY, + ignoredCount: 0, + }, + }); + + await npsSurveyStore.showNpsSurveyIfPossible(); + + expect(openModal).toHaveBeenCalledWith(NPS_SURVEY_MODAL_KEY); + expect(updateNpsSurveyState).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: '/rest', + }), + { + ignoredCount: 0, + lastShownAt: NOW, + waitingForResponse: true, + }, + ); + }); + + it('increments ignore count when survey is ignored', async () => { + npsSurveyStore.setupNpsSurveyOnLogin('1', { + userActivated: true, + userActivatedAt: NOW - 30 * 7 * TIME.DAY, + npsSurvey: { + responded: true, + lastShownAt: NOW - (30 * 6 + 1) * TIME.DAY, + }, + }); + + await npsSurveyStore.ignoreNpsSurvey(); + + expect(updateNpsSurveyState).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: '/rest', + }), + { + ignoredCount: 1, + lastShownAt: NOW - (30 * 6 + 1) * TIME.DAY, + waitingForResponse: true, + }, + ); + }); + + it('updates state to responded if ignored more than maximum times', async () => { + npsSurveyStore.setupNpsSurveyOnLogin('1', { + userActivated: true, + userActivatedAt: NOW - 30 * 7 * TIME.DAY, + npsSurvey: { + waitingForResponse: true, + lastShownAt: NOW - (30 * 6 + 1) * TIME.DAY, + ignoredCount: 2, + }, + }); + + await npsSurveyStore.ignoreNpsSurvey(); + + expect(updateNpsSurveyState).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: '/rest', + }), + { + lastShownAt: NOW - (30 * 6 + 1) * TIME.DAY, + responded: true, + }, + ); + }); + + it('updates state to responded when response is given', async () => { + npsSurveyStore.setupNpsSurveyOnLogin('1', { + userActivated: true, + userActivatedAt: NOW - 30 * 7 * TIME.DAY, + npsSurvey: { + responded: true, + lastShownAt: NOW - (30 * 6 + 1) * TIME.DAY, + }, + }); + + await npsSurveyStore.respondNpsSurvey(); + + expect(updateNpsSurveyState).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: '/rest', + }), + { + responded: true, + lastShownAt: NOW - (30 * 6 + 1) * TIME.DAY, + }, + ); + }); + + it('does not show nps survey twice in the same session', async () => { + npsSurveyStore.setupNpsSurveyOnLogin('1', { + userActivated: true, + userActivatedAt: NOW - THREE_DAYS_IN_MILLIS - 10000, + }); + + await npsSurveyStore.showNpsSurveyIfPossible(); + + expect(openModal).toHaveBeenCalledWith(NPS_SURVEY_MODAL_KEY); + expect(updateNpsSurveyState).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: '/rest', + }), + { + ignoredCount: 0, + lastShownAt: NOW, + waitingForResponse: true, + }, + ); + + openModal.mockReset(); + updateNpsSurveyState.mockReset(); + + await npsSurveyStore.showNpsSurveyIfPossible(); + expect(openModal).not.toHaveBeenCalled(); + expect(updateNpsSurveyState).not.toHaveBeenCalled(); + }); + + it('resets on logout, preventing nps survey from showing', async () => { + npsSurveyStore.setupNpsSurveyOnLogin('1', { + userActivated: true, + userActivatedAt: NOW - THREE_DAYS_IN_MILLIS - 10000, + }); + + npsSurveyStore.resetNpsSurveyOnLogOut(); + await npsSurveyStore.showNpsSurveyIfPossible(); + + expect(openModal).not.toHaveBeenCalled(); + expect(updateNpsSurveyState).not.toHaveBeenCalled(); + }); + + it('if telemetry is disabled, does not show nps survey', async () => { + useSettingsStore().settings.telemetry = { enabled: false }; + npsSurveyStore.setupNpsSurveyOnLogin('1', { + userActivated: true, + userActivatedAt: NOW - THREE_DAYS_IN_MILLIS - 10000, + }); + + await npsSurveyStore.showNpsSurveyIfPossible(); + + expect(openModal).not.toHaveBeenCalled(); + expect(updateNpsSurveyState).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/editor-ui/src/stores/npsSurvey.store.ts b/packages/editor-ui/src/stores/npsSurvey.store.ts new file mode 100644 index 0000000000000..5abe950f1ca2d --- /dev/null +++ b/packages/editor-ui/src/stores/npsSurvey.store.ts @@ -0,0 +1,166 @@ +import { ref } from 'vue'; +import { defineStore } from 'pinia'; +import { useUIStore } from './ui.store'; +import { + SEVEN_DAYS_IN_MILLIS, + SIX_MONTHS_IN_MILLIS, + THREE_DAYS_IN_MILLIS, + NPS_SURVEY_MODAL_KEY, + CONTACT_PROMPT_MODAL_KEY, +} from '@/constants'; +import { useRootStore } from './n8nRoot.store'; +import type { IUserSettings, NpsSurveyState } from 'n8n-workflow'; +import { useSettingsStore } from './settings.store'; +import { updateNpsSurveyState } from '@/api/npsSurvey'; +import type { IN8nPrompts } from '@/Interface'; +import { getPromptsData } from '@/api/settings'; +import { assert } from '@/utils/assert'; + +export const MAXIMUM_TIMES_TO_SHOW_SURVEY_IF_IGNORED = 3; + +export const useNpsSurveyStore = defineStore('npsSurvey', () => { + const rootStore = useRootStore(); + const uiStore = useUIStore(); + const settingsStore = useSettingsStore(); + + const shouldShowNpsSurveyNext = ref(false); + const currentSurveyState = ref(); + const currentUserId = ref(); + const promptsData = ref(); + + function setupNpsSurveyOnLogin(userId: string, settings?: IUserSettings): void { + currentUserId.value = userId; + + if (settings) { + setShouldShowNpsSurvey(settings); + } + } + + function setShouldShowNpsSurvey(settings: IUserSettings) { + if (!settingsStore.isTelemetryEnabled) { + shouldShowNpsSurveyNext.value = false; + return; + } + + currentSurveyState.value = settings.npsSurvey; + const userActivated = Boolean(settings.userActivated); + const userActivatedAt = settings.userActivatedAt; + const lastShownAt = currentSurveyState.value?.lastShownAt; + + if (!userActivated || !userActivatedAt) { + return; + } + + const timeSinceActivation = Date.now() - userActivatedAt; + if (timeSinceActivation < THREE_DAYS_IN_MILLIS) { + return; + } + + if (!currentSurveyState.value || !lastShownAt) { + // user has activated but never seen the nps survey + shouldShowNpsSurveyNext.value = true; + return; + } + + const timeSinceLastShown = Date.now() - lastShownAt; + if ('responded' in currentSurveyState.value && timeSinceLastShown < SIX_MONTHS_IN_MILLIS) { + return; + } + if ( + 'waitingForResponse' in currentSurveyState.value && + timeSinceLastShown < SEVEN_DAYS_IN_MILLIS + ) { + return; + } + + shouldShowNpsSurveyNext.value = true; + } + + function resetNpsSurveyOnLogOut() { + shouldShowNpsSurveyNext.value = false; + } + + async function showNpsSurveyIfPossible() { + if (!shouldShowNpsSurveyNext.value) { + return; + } + + uiStore.openModal(NPS_SURVEY_MODAL_KEY); + shouldShowNpsSurveyNext.value = false; + + const updatedState: NpsSurveyState = { + waitingForResponse: true, + lastShownAt: Date.now(), + ignoredCount: + currentSurveyState.value && 'ignoredCount' in currentSurveyState.value + ? currentSurveyState.value.ignoredCount + : 0, + }; + await updateNpsSurveyState(rootStore.getRestApiContext, updatedState); + currentSurveyState.value = updatedState; + } + + async function respondNpsSurvey() { + assert(currentSurveyState.value); + + const updatedState: NpsSurveyState = { + responded: true, + lastShownAt: currentSurveyState.value.lastShownAt, + }; + await updateNpsSurveyState(rootStore.getRestApiContext, updatedState); + currentSurveyState.value = updatedState; + } + + async function ignoreNpsSurvey() { + assert(currentSurveyState.value); + + const state = currentSurveyState.value; + const ignoredCount = 'ignoredCount' in state ? state.ignoredCount : 0; + + if (ignoredCount + 1 >= MAXIMUM_TIMES_TO_SHOW_SURVEY_IF_IGNORED) { + await respondNpsSurvey(); + + return; + } + + const updatedState: NpsSurveyState = { + waitingForResponse: true, + lastShownAt: currentSurveyState.value.lastShownAt, + ignoredCount: ignoredCount + 1, + }; + await updateNpsSurveyState(rootStore.getRestApiContext, updatedState); + currentSurveyState.value = updatedState; + } + + async function fetchPromptsData(): Promise { + assert(currentUserId.value); + if (!settingsStore.isTelemetryEnabled) { + return; + } + + try { + promptsData.value = await getPromptsData( + settingsStore.settings.instanceId, + currentUserId.value, + ); + } catch (e) { + console.error('Failed to fetch prompts data'); + } + + if (promptsData.value?.showContactPrompt) { + uiStore.openModal(CONTACT_PROMPT_MODAL_KEY); + } else { + await useNpsSurveyStore().showNpsSurveyIfPossible(); + } + } + + return { + promptsData, + resetNpsSurveyOnLogOut, + showNpsSurveyIfPossible, + ignoreNpsSurvey, + respondNpsSurvey, + setupNpsSurveyOnLogin, + fetchPromptsData, + }; +}); diff --git a/packages/editor-ui/src/stores/settings.store.ts b/packages/editor-ui/src/stores/settings.store.ts index e545dfdc1e214..69ee1b8595d0a 100644 --- a/packages/editor-ui/src/stores/settings.store.ts +++ b/packages/editor-ui/src/stores/settings.store.ts @@ -6,22 +6,15 @@ import { testLdapConnection, updateLdapConfig, } from '@/api/ldap'; -import { getPromptsData, getSettings, submitContactInfo, submitValueSurvey } from '@/api/settings'; +import { getSettings, submitContactInfo } from '@/api/settings'; import { testHealthEndpoint } from '@/api/templates'; -import type { EnterpriseEditionFeatureValue } from '@/Interface'; -import { - CONTACT_PROMPT_MODAL_KEY, - STORES, - VALUE_SURVEY_MODAL_KEY, - INSECURE_CONNECTION_WARNING, -} from '@/constants'; import type { + EnterpriseEditionFeatureValue, ILdapConfig, IN8nPromptResponse, - IN8nPrompts, - IN8nValueSurveyData, ISettingsState, } from '@/Interface'; +import { STORES, INSECURE_CONNECTION_WARNING } from '@/constants'; import { UserManagementAuthenticationMethod } from '@/Interface'; import type { IDataObject, @@ -45,7 +38,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, { state: (): ISettingsState => ({ initialized: false, settings: {} as IN8nUISettings, - promptsData: {} as IN8nPrompts, userManagement: { quota: -1, showSetupOnFirstLoad: false, @@ -311,32 +303,9 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, { }, }; }, - setPromptsData(promptsData: IN8nPrompts): void { - this.promptsData = promptsData; - }, setAllowedModules(allowedModules: { builtIn?: string[]; external?: string[] }): void { this.settings.allowedModules = allowedModules; }, - async fetchPromptsData(): Promise { - if (!this.isTelemetryEnabled) { - return; - } - - const uiStore = useUIStore(); - const usersStore = useUsersStore(); - const promptsData: IN8nPrompts = await getPromptsData( - this.settings.instanceId, - usersStore.currentUserId || '', - ); - - if (promptsData && promptsData.showContactPrompt) { - uiStore.openModal(CONTACT_PROMPT_MODAL_KEY); - } else if (promptsData && promptsData.showValueSurvey) { - uiStore.openModal(VALUE_SURVEY_MODAL_KEY); - } - - this.setPromptsData(promptsData); - }, async submitContactInfo(email: string): Promise { try { const usersStore = useUsersStore(); @@ -349,18 +318,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, { return; } }, - async submitValueSurvey(params: IN8nValueSurveyData): Promise { - try { - const usersStore = useUsersStore(); - return await submitValueSurvey( - this.settings.instanceId, - usersStore.currentUserId || '', - params, - ); - } catch (error) { - return; - } - }, async testTemplatesEndpoint(): Promise { const timeout = new Promise((_, reject) => setTimeout(() => reject(), 2000)); await Promise.race([testHealthEndpoint(this.templatesHost), timeout]); diff --git a/packages/editor-ui/src/stores/ui.store.ts b/packages/editor-ui/src/stores/ui.store.ts index d762d2f82fb8d..4c667f7b38fe8 100644 --- a/packages/editor-ui/src/stores/ui.store.ts +++ b/packages/editor-ui/src/stores/ui.store.ts @@ -24,7 +24,7 @@ import { PERSONALIZATION_MODAL_KEY, STORES, TAGS_MANAGER_MODAL_KEY, - VALUE_SURVEY_MODAL_KEY, + NPS_SURVEY_MODAL_KEY, VERSIONS_MODAL_KEY, VIEWS, WORKFLOW_ACTIVE_MODAL_KEY, @@ -55,6 +55,7 @@ import type { AppliedThemeOption, NotificationOptions, ModalState, + ModalKey, } from '@/Interface'; import { defineStore } from 'pinia'; import { useRootStore } from '@/stores/n8nRoot.store'; @@ -104,7 +105,7 @@ export const useUIStore = defineStore(STORES.UI, { PERSONALIZATION_MODAL_KEY, INVITE_USER_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, - VALUE_SURVEY_MODAL_KEY, + NPS_SURVEY_MODAL_KEY, VERSIONS_MODAL_KEY, WORKFLOW_LM_CHAT_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, @@ -278,19 +279,19 @@ export const useUIStore = defineStore(STORES.UI, { return this.modals[VERSIONS_MODAL_KEY].open; }, isModalOpen() { - return (name: string) => this.modals[name].open; + return (name: ModalKey) => this.modals[name].open; }, isModalActive() { - return (name: string) => this.modalStack.length > 0 && name === this.modalStack[0]; + return (name: ModalKey) => this.modalStack.length > 0 && name === this.modalStack[0]; }, getModalActiveId() { - return (name: string) => this.modals[name].activeId; + return (name: ModalKey) => this.modals[name].activeId; }, getModalMode() { - return (name: string) => this.modals[name].mode; + return (name: ModalKey) => this.modals[name].mode; }, getModalData() { - return (name: string) => this.modals[name].data; + return (name: ModalKey) => this.modals[name].data; }, getFakeDoorByLocation() { return (location: IFakeDoorLocation) => diff --git a/packages/editor-ui/src/stores/users.store.ts b/packages/editor-ui/src/stores/users.store.ts index 6ca7a127034fa..c524809add7df 100644 --- a/packages/editor-ui/src/stores/users.store.ts +++ b/packages/editor-ui/src/stores/users.store.ts @@ -42,6 +42,7 @@ import { confirmEmail, getCloudUserInfo } from '@/api/cloudPlans'; import { useRBACStore } from '@/stores/rbac.store'; import type { Scope } from '@n8n/permissions'; import { inviteUsers, acceptInvitation } from '@/api/invitation'; +import { useNpsSurveyStore } from './npsSurvey.store'; const isPendingUser = (user: IUserResponse | null) => !!user?.isPending; const isInstanceOwner = (user: IUserResponse | null) => user?.role === ROLE.Owner; @@ -110,6 +111,7 @@ export const useUsersStore = defineStore(STORES.USERS, { const defaultScopes: Scope[] = []; useRBACStore().setGlobalScopes(user.globalScopes || defaultScopes); usePostHog().init(user.featureFlags); + useNpsSurveyStore().setupNpsSurveyOnLogin(user.id, user.settings); }, unsetCurrentUser() { this.currentUserId = null; @@ -185,6 +187,7 @@ export const useUsersStore = defineStore(STORES.USERS, { useCloudPlanStore().reset(); usePostHog().reset(); useUIStore().clearBannerStack(); + useNpsSurveyStore().resetNpsSurveyOnLogOut(); }, async createOwner(params: { firstName: string; diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 8189058d27ced..9af97218bd7e8 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -403,6 +403,7 @@ import { useAIStore } from '@/stores/ai.store'; import { useStorage } from '@/composables/useStorage'; import { isJSPlumbEndpointElement, isJSPlumbConnection } from '@/utils/typeGuards'; import { usePostHog } from '@/stores/posthog.store'; +import { useNpsSurveyStore } from '@/stores/npsSurvey.store'; interface AddNodeOptions { position?: XYPosition; @@ -464,7 +465,7 @@ export default defineComponent({ this.workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID); const saved = await this.workflowHelpers.saveCurrentWorkflow({}, false); if (saved) { - await this.settingsStore.fetchPromptsData(); + await this.npsSurveyStore.fetchPromptsData(); } this.uiStore.stateIsDirty = false; @@ -605,6 +606,7 @@ export default defineComponent({ useExecutionsStore, useProjectsStore, useAIStore, + useNpsSurveyStore, ), nativelyNumberSuffixedDefaults(): string[] { return this.nodeTypesStore.nativelyNumberSuffixedDefaults; @@ -1235,7 +1237,7 @@ export default defineComponent({ async onSaveKeyboardShortcut(e: KeyboardEvent) { let saved = await this.workflowHelpers.saveCurrentWorkflow(); if (saved) { - await this.settingsStore.fetchPromptsData(); + await this.npsSurveyStore.fetchPromptsData(); if (this.$route.name === VIEWS.EXECUTION_DEBUG) { await this.$router.replace({ @@ -3796,7 +3798,7 @@ export default defineComponent({ ); if (confirmModal === MODAL_CONFIRM) { const saved = await this.workflowHelpers.saveCurrentWorkflow(); - if (saved) await this.settingsStore.fetchPromptsData(); + if (saved) await this.npsSurveyStore.fetchPromptsData(); } else if (confirmModal === MODAL_CANCEL) { return; } diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 45aaefb2854a9..39add47db27c8 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2517,11 +2517,21 @@ export interface IUserManagementSettings { authenticationMethod: AuthenticationMethod; } +export type NpsSurveyRespondedState = { lastShownAt: number; responded: true }; +export type NpsSurveyWaitingState = { + lastShownAt: number; + waitingForResponse: true; + ignoredCount: number; +}; +export type NpsSurveyState = NpsSurveyRespondedState | NpsSurveyWaitingState; + export interface IUserSettings { isOnboarded?: boolean; firstSuccessfulWorkflowId?: string; userActivated?: boolean; + userActivatedAt?: number; allowSSOManualLogin?: boolean; + npsSurvey?: NpsSurveyState; } export interface IPublicApiSettings { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0a71cdaed553..bcb10455873d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: '@ngneat/falso': specifier: ^6.4.0 version: 6.4.0 + '@sinonjs/fake-timers': + specifier: ^11.2.2 + version: 11.2.2 cross-env: specifier: ^7.0.3 version: 7.0.3 @@ -4671,9 +4674,15 @@ packages: '@sinonjs/commons@2.0.0': resolution: {integrity: sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==} + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + '@sinonjs/fake-timers@10.0.2': resolution: {integrity: sha512-SwUDyjWnah1AaNl7kxsa7cfLhlTYoiyhDAIgyh+El30YvXs/o7OLXpYH88Zdhyx9JExKrmHDJ+10bwIcY80Jmw==} + '@sinonjs/fake-timers@11.2.2': + resolution: {integrity: sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==} + '@smithy/abort-controller@2.0.15': resolution: {integrity: sha512-JkS36PIS3/UCbq/MaozzV7jECeL+BTt4R75bwY8i+4RASys4xOyUS1HsRyUNSqUXFP4QyCz5aNnh3ltuaxv+pw==} engines: {node: '>=14.0.0'} @@ -13775,9 +13784,6 @@ packages: zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} -onlyBuiltDependencies: - - sqlite3 - snapshots: '@aashutoshrathi/word-wrap@1.2.6': {} @@ -17781,10 +17787,18 @@ snapshots: dependencies: type-detect: 4.0.8 + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + '@sinonjs/fake-timers@10.0.2': dependencies: '@sinonjs/commons': 2.0.0 + '@sinonjs/fake-timers@11.2.2': + dependencies: + '@sinonjs/commons': 3.0.1 + '@smithy/abort-controller@2.0.15': dependencies: '@smithy/types': 2.12.0 From 6ba789aa6d8c7420fbfdd2e61a3c2ac2c44f850f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=A4=95=E0=A4=BE=E0=A4=B0=E0=A4=A4=E0=A5=8B=E0=A4=AB?= =?UTF-8?q?=E0=A5=8D=E0=A4=AB=E0=A5=87=E0=A4=B2=E0=A4=B8=E0=A5=8D=E0=A4=95?= =?UTF-8?q?=E0=A5=8D=E0=A4=B0=E0=A4=BF=E0=A4=AA=E0=A5=8D=E0=A4=9F=E2=84=A2?= Date: Tue, 11 Jun 2024 10:28:52 +0200 Subject: [PATCH 19/20] ci: Remove unused WaitTracker mocking (no-changelog) (#9694) --- packages/cli/test/integration/commands/ldap/reset.test.ts | 4 ---- .../cli/test/integration/environments/SourceControl.test.ts | 5 ----- packages/cli/test/integration/executions.controller.test.ts | 5 ----- ...kflows.controller-with-active-workflow-manager.ee.test.ts | 5 ----- 4 files changed, 19 deletions(-) diff --git a/packages/cli/test/integration/commands/ldap/reset.test.ts b/packages/cli/test/integration/commands/ldap/reset.test.ts index 6bbd15eddeaec..9a0ebecd516c2 100644 --- a/packages/cli/test/integration/commands/ldap/reset.test.ts +++ b/packages/cli/test/integration/commands/ldap/reset.test.ts @@ -17,7 +17,6 @@ import { Push } from '@/push'; import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; import { SharedCredentialsRepository } from '@/databases/repositories/sharedCredentials.repository'; import { createTeamProject, findProject, getPersonalProject } from '../../shared/db/projects'; -import { WaitTracker } from '@/WaitTracker'; import { getLdapSynchronizations, saveLdapSynchronization } from '@/Ldap/helpers'; import { createLdapConfig } from '../../shared/ldap'; import { LdapService } from '@/Ldap/ldap.service'; @@ -40,9 +39,6 @@ beforeAll(async () => { mockInstance(Push); mockInstance(InternalHooks); mockInstance(LoadNodesAndCredentials); - // This needs to be mocked, otherwise the time setInterval would prevent jest - // from exiting properly. - mockInstance(WaitTracker); await testDb.init(); await oclifConfig.load(); }); diff --git a/packages/cli/test/integration/environments/SourceControl.test.ts b/packages/cli/test/integration/environments/SourceControl.test.ts index 84eec3c0dae57..c64981c096612 100644 --- a/packages/cli/test/integration/environments/SourceControl.test.ts +++ b/packages/cli/test/integration/environments/SourceControl.test.ts @@ -5,19 +5,14 @@ import config from '@/config'; import { SourceControlPreferencesService } from '@/environments/sourceControl/sourceControlPreferences.service.ee'; import { SourceControlService } from '@/environments/sourceControl/sourceControl.service.ee'; import type { SourceControlledFile } from '@/environments/sourceControl/types/sourceControlledFile'; -import { WaitTracker } from '@/WaitTracker'; import * as utils from '../shared/utils/'; import { createUser } from '../shared/db/users'; -import { mockInstance } from '../../shared/mocking'; import type { SuperAgentTest } from '../shared/types'; let authOwnerAgent: SuperAgentTest; let owner: User; -// This is necessary for the tests to shutdown cleanly. -mockInstance(WaitTracker); - const testServer = utils.setupTestServer({ endpointGroups: ['sourceControl', 'license', 'auth'], enabledFeatures: ['feat:sourceControl', 'feat:sharing'], diff --git a/packages/cli/test/integration/executions.controller.test.ts b/packages/cli/test/integration/executions.controller.test.ts index 23c4cd836eb09..e73cb98b4753a 100644 --- a/packages/cli/test/integration/executions.controller.test.ts +++ b/packages/cli/test/integration/executions.controller.test.ts @@ -5,8 +5,6 @@ import { createMember, createOwner } from './shared/db/users'; import { createWorkflow, shareWorkflowWithUsers } from './shared/db/workflows'; import * as testDb from './shared/testDb'; import { setupTestServer } from './shared/utils'; -import { mockInstance } from '../shared/mocking'; -import { WaitTracker } from '@/WaitTracker'; import { createTeamProject, linkUserToProject } from './shared/db/projects'; const testServer = setupTestServer({ endpointGroups: ['executions'] }); @@ -14,9 +12,6 @@ const testServer = setupTestServer({ endpointGroups: ['executions'] }); let owner: User; let member: User; -// This is necessary for the tests to shutdown cleanly. -mockInstance(WaitTracker); - const saveExecution = async ({ belongingTo }: { belongingTo: User }) => { const workflow = await createWorkflow({}, belongingTo); return await createSuccessfulExecution(workflow); diff --git a/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts index f4387f30ad37b..42f3a49e06197 100644 --- a/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller-with-active-workflow-manager.ee.test.ts @@ -5,8 +5,6 @@ import * as testDb from '../shared/testDb'; import { createUser } from '../shared/db/users'; import { createWorkflowWithTrigger } from '../shared/db/workflows'; import { createTeamProject } from '../shared/db/projects'; -import { mockInstance } from '../../shared/mocking'; -import { WaitTracker } from '@/WaitTracker'; let member: User; let anotherMember: User; @@ -16,9 +14,6 @@ const testServer = utils.setupTestServer({ enabledFeatures: ['feat:sharing', 'feat:advancedPermissions'], }); -// This is necessary for the tests to shutdown cleanly. -mockInstance(WaitTracker); - beforeAll(async () => { member = await createUser({ role: 'global:member' }); anotherMember = await createUser({ role: 'global:member' }); From c0e4f69fe6fb994a671f74ad65912abc4d74bed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Tue, 11 Jun 2024 10:53:16 +0200 Subject: [PATCH 20/20] refactor(core): Remove more dead code from event bus (no-changelog) (#9697) --- .../eventbus/EventMessageClasses/Helpers.ts | 102 ------------------ .../MessageEventBusLogWriter.ts | 31 +++++- 2 files changed, 27 insertions(+), 106 deletions(-) delete mode 100644 packages/cli/src/eventbus/EventMessageClasses/Helpers.ts diff --git a/packages/cli/src/eventbus/EventMessageClasses/Helpers.ts b/packages/cli/src/eventbus/EventMessageClasses/Helpers.ts deleted file mode 100644 index 31f3e4d106c22..0000000000000 --- a/packages/cli/src/eventbus/EventMessageClasses/Helpers.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { EventMessageTypes } from '.'; -import type { EventMessageGenericOptions } from './EventMessageGeneric'; -import { EventMessageGeneric } from './EventMessageGeneric'; -import type { AbstractEventMessageOptions } from './AbstractEventMessageOptions'; -import type { EventMessageWorkflowOptions } from './EventMessageWorkflow'; -import { EventMessageWorkflow } from './EventMessageWorkflow'; -import { EventMessageTypeNames } from 'n8n-workflow'; -import type { EventMessageAuditOptions } from './EventMessageAudit'; -import { EventMessageAudit } from './EventMessageAudit'; -import type { EventMessageNodeOptions } from './EventMessageNode'; -import { EventMessageNode } from './EventMessageNode'; - -export const getEventMessageObjectByType = ( - message: AbstractEventMessageOptions, -): EventMessageTypes | null => { - switch (message.__type as EventMessageTypeNames) { - case EventMessageTypeNames.generic: - return new EventMessageGeneric(message as EventMessageGenericOptions); - case EventMessageTypeNames.workflow: - return new EventMessageWorkflow(message as EventMessageWorkflowOptions); - case EventMessageTypeNames.audit: - return new EventMessageAudit(message as EventMessageAuditOptions); - case EventMessageTypeNames.node: - return new EventMessageNode(message as EventMessageNodeOptions); - default: - return null; - } -}; - -interface StringIndexedObject { - [key: string]: StringIndexedObject | string; -} - -export function eventGroupFromEventName(eventName: string): string | undefined { - const matches = eventName.match(/^[\w\s]+\.[\w\s]+/); - if (matches && matches?.length > 0) { - return matches[0]; - } - return; -} - -function dotsToObject2(dottedString: string, o?: StringIndexedObject): StringIndexedObject { - const rootObject: StringIndexedObject = o ?? {}; - if (!dottedString) return rootObject; - - const parts = dottedString.split('.'); /*?*/ - - let part: string | undefined; - let obj: StringIndexedObject = rootObject; - while ((part = parts.shift())) { - if (typeof obj[part] !== 'object') { - obj[part] = { - __name: part, - }; - } - obj = obj[part] as StringIndexedObject; - } - return rootObject; -} - -export function eventListToObject(dottedList: string[]): object { - const result = {}; - dottedList.forEach((e) => { - dotsToObject2(e, result); - }); - return result; -} - -interface StringIndexedChild { - name: string; - children: StringIndexedChild[]; -} - -export function eventListToObjectTree(dottedList: string[]): StringIndexedChild { - const x: StringIndexedChild = { - name: 'eventTree', - children: [] as unknown as StringIndexedChild[], - }; - dottedList.forEach((dottedString: string) => { - const parts = dottedString.split('.'); - - let part: string | undefined; - let children = x.children; - while ((part = parts.shift())) { - if (part) { - // eslint-disable-next-line @typescript-eslint/no-loop-func - const foundChild = children.find((e) => e.name === part); - if (foundChild) { - children = foundChild.children; - } else { - const newChild: StringIndexedChild = { - name: part, - children: [], - }; - children.push(newChild); - children = newChild.children; - } - } - } - }); - return x; -} diff --git a/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts b/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts index 7565cb481482b..6c59b2e24e9cc 100644 --- a/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts +++ b/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts @@ -6,10 +6,18 @@ import path, { parse } from 'path'; import { Worker } from 'worker_threads'; import { createReadStream, existsSync, rmSync } from 'fs'; import readline from 'readline'; -import { jsonParse } from 'n8n-workflow'; import remove from 'lodash/remove'; import config from '@/config'; -import { getEventMessageObjectByType } from '../EventMessageClasses/Helpers'; +import type { EventMessageGenericOptions } from '../EventMessageClasses/EventMessageGeneric'; +import { EventMessageGeneric } from '../EventMessageClasses/EventMessageGeneric'; +import type { AbstractEventMessageOptions } from '../EventMessageClasses/AbstractEventMessageOptions'; +import type { EventMessageWorkflowOptions } from '../EventMessageClasses/EventMessageWorkflow'; +import { EventMessageWorkflow } from '../EventMessageClasses/EventMessageWorkflow'; +import { EventMessageTypeNames, jsonParse } from 'n8n-workflow'; +import type { EventMessageAuditOptions } from '../EventMessageClasses/EventMessageAudit'; +import { EventMessageAudit } from '../EventMessageClasses/EventMessageAudit'; +import type { EventMessageNodeOptions } from '../EventMessageClasses/EventMessageNode'; +import { EventMessageNode } from '../EventMessageClasses/EventMessageNode'; import type { EventMessageReturnMode } from '../MessageEventBus/MessageEventBus'; import type { EventMessageTypes } from '../EventMessageClasses'; import type { EventMessageConfirmSource } from '../EventMessageClasses/EventMessageConfirm'; @@ -200,7 +208,7 @@ export class MessageEventBusLogWriter { try { const json = jsonParse(line); if (isEventMessageOptions(json) && json.__type !== undefined) { - const msg = getEventMessageObjectByType(json); + const msg = this.getEventMessageObjectByType(json); if (msg !== null) results.loggedMessages.push(msg); if (msg?.eventName && msg.payload?.executionId) { const executionId = msg.payload.executionId as string; @@ -302,7 +310,7 @@ export class MessageEventBusLogWriter { json.__type !== undefined && json.payload?.executionId === executionId ) { - const msg = getEventMessageObjectByType(json); + const msg = this.getEventMessageObjectByType(json); if (msg !== null) messages.push(msg); } } catch { @@ -346,4 +354,19 @@ export class MessageEventBusLogWriter { unfinishedExecutions: result.unfinishedExecutions, }; } + + getEventMessageObjectByType(message: AbstractEventMessageOptions): EventMessageTypes | null { + switch (message.__type as EventMessageTypeNames) { + case EventMessageTypeNames.generic: + return new EventMessageGeneric(message as EventMessageGenericOptions); + case EventMessageTypeNames.workflow: + return new EventMessageWorkflow(message as EventMessageWorkflowOptions); + case EventMessageTypeNames.audit: + return new EventMessageAudit(message as EventMessageAuditOptions); + case EventMessageTypeNames.node: + return new EventMessageNode(message as EventMessageNodeOptions); + default: + return null; + } + } }