diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml index b5cc8a38c60aa..49c37fd4c896b 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' @@ -164,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.config.js + spec: '${{ inputs.spec }}' 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..fae27a545c8af 100644 --- a/cypress/e2e/12-canvas-actions.cy.ts +++ b/cypress/e2e/12-canvas-actions.cy.ts @@ -1,3 +1,5 @@ +import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; +import { successToast } from '../pages/notifications'; import { MANUAL_TRIGGER_NODE_NAME, MANUAL_TRIGGER_NODE_DISPLAY_NAME, @@ -7,7 +9,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', () => { @@ -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/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..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(); @@ -136,12 +137,10 @@ 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 - .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', () => { @@ -151,10 +150,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 }]'); + 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 +165,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/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/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..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,10 +184,10 @@ 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`, () => { + it('should allow user to change their personal data', () => { personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password); personalSettingsPage.actions.updateFirstAndLastName( updatedPersonalData.newFirstName, @@ -196,42 +196,39 @@ 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`, () => { + 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); - 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`, () => { + it('should change current user password', () => { personalSettingsPage.actions.loginAndVisit(INSTANCE_OWNER.email, INSTANCE_OWNER.password); personalSettingsPage.getters.changePasswordLink().click(); personalSettingsPage.actions.updatePassword( 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, ); }); - 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,13 +239,13 @@ 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, ); 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 84b71e08853de..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'); }); }); @@ -512,8 +513,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 +539,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 +559,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); }); }); @@ -590,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', () => { @@ -617,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 1a9776d280af1..a9f710bf7c3e2 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, @@ -11,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(); @@ -152,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') @@ -209,7 +211,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..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(); @@ -7,15 +8,12 @@ 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 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/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..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', @@ -27,14 +25,14 @@ 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]); 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/34-template-credentials-setup.cy.ts b/cypress/e2e/34-template-credentials-setup.cy.ts index 7de435c4fafaa..7553a55a7b9df 100644 --- a/cypress/e2e/34-template-credentials-setup.cy.ts +++ b/cypress/e2e/34-template-credentials-setup.cy.ts @@ -34,7 +34,7 @@ describe('Template credentials setup', () => { }); cy.intercept('GET', '**/rest/settings', (req) => { // Disable cache - delete req.headers['if-none-match'] + delete req.headers['if-none-match']; req.reply((res) => { if (res.body.data) { // Disable custom templates host if it has been overridden by another intercept @@ -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 @@ -117,6 +117,7 @@ describe('Template credentials setup', () => { const workflow = JSON.parse(workflowJSON); expect(workflow.meta).to.haveOwnProperty('templateId', testTemplate.id.toString()); + expect(workflow.meta).not.to.haveOwnProperty('templateCredsSetupCompleted'); workflow.nodes.forEach((node: any) => { expect(Object.keys(node.credentials ?? {})).to.have.lengthOf(1); }); diff --git a/cypress/e2e/39-import-workflow.cy.ts b/cypress/e2e/39-import-workflow.cy.ts index 831228fba35d9..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'); }); }); @@ -64,7 +65,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/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 0ebd859174e85..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(); @@ -67,7 +68,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 +306,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 +334,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 +437,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 +483,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 +598,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(); @@ -734,14 +735,14 @@ 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'); }); }); 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/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 c0875d93f73f8..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,6 +37,20 @@ describe('Workflow Actions', () => { WorkflowPage.getters.isWorkflowSaved(); }); + 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); + 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'); }); @@ -58,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(); @@ -160,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)', () => { @@ -225,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'); }); @@ -243,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); }); @@ -272,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(() => { @@ -317,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', () => { @@ -336,9 +351,8 @@ 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'); }); - }); 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..ffc6404e37639 --- /dev/null +++ b/cypress/package.json @@ -0,0 +1,29 @@ +{ + "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", + "@sinonjs/fake-timers": "^11.2.2", + "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 6f8b4693d1259..abcb9cc1f157e 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 = { @@ -178,7 +178,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, @@ -187,7 +187,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) => { @@ -285,7 +285,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/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/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..234da9c9e557e 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -1,13 +1,12 @@ 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]; - const nodeCreator = new NodeCreator(); export class WorkflowPage extends BasePage { url = '/workflow/new'; + getters = { workflowNameInputContainer: () => cy.getByTestId('workflow-name-input', { timeout: 5000 }), workflowNameInput: () => @@ -48,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'), @@ -134,12 +128,15 @@ export class WorkflowPage extends BasePage { colors: () => cy.getByTestId('color'), contextMenuAction: (action: string) => cy.getByTestId(`context-menu-item-${action}`), }; + actions = { - visit: (preventNodeViewUnload = true) => { + visit: (preventNodeViewUnload = true, appDate?: number) => { cy.visit(this.url); + if (appDate) { + cy.setAppDate(appDate); + } cy.waitForLoad(); cy.window().then((win) => { - // @ts-ignore win.preventNodeViewBeforeUnload = preventNodeViewUnload; }); }, @@ -329,15 +326,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 +387,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 +410,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..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); }); @@ -16,9 +27,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 +55,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 +137,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..247dc5745ecbc 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,12 +45,16 @@ 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; }; } >; resetDatabase(): void; + setAppDate(targetDate: number | Date): 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/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/Interfaces.ts b/packages/cli/src/Interfaces.ts index 9a6cffd782a22..11d0ea6cd5528 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -185,7 +185,7 @@ export interface IExecutionsCurrentSummary { startedAt: Date; mode: WorkflowExecuteMode; workflowId: string; - status?: ExecutionStatus; + status: ExecutionStatus; } export interface IExecutingWorkflowData { diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index fa30e8392d2e2..93ab6141b95f7 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'; @@ -72,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); @@ -119,7 +119,6 @@ export class Server extends AbstractServer { const controllers: Array> = [ EventBusController, - EventBusControllerEE, AuthController, LicenseController, OAuth1CredentialController, @@ -150,6 +149,7 @@ export class Server extends AbstractServer { ProjectController, RoleController, CurlController, + UserSettingsController, ]; if ( 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/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/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/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/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/MessageEventBus/MessageEventBus.ts b/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts index 6bc7d7b6a7d9c..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'; @@ -22,21 +21,18 @@ 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 { ExecutionDataRecoveryService } from '../executionDataRecovery.service'; +import { ExecutionRecoveryService } from '../../executions/execution-recovery.service'; import { EventMessageAiNode, type EventMessageAiNodeOptions, } from '../EventMessageClasses/EventMessageAiNode'; +import { License } from '@/License'; export type EventMessageReturnMode = 'sent' | 'unsent' | 'all' | 'unfinished'; @@ -68,7 +64,8 @@ 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, + private readonly license: License, ) { super(); } @@ -185,10 +182,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, ); } } @@ -247,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) { @@ -330,7 +315,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! @@ -351,7 +336,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/MessageEventBusWriter/MessageEventBusLogWriter.ts b/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts index 918e79e523ce3..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'; @@ -97,15 +105,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: {} }); @@ -209,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; @@ -311,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 { @@ -355,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; + } + } } 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 95433a359b334..0000000000000 --- a/packages/cli/src/eventbus/eventBus.controller.ee.ts +++ /dev/null @@ -1,135 +0,0 @@ -import express from 'express'; -import type { - MessageEventBusDestinationWebhookOptions, - MessageEventBusDestinationOptions, -} from 'n8n-workflow'; -import { MessageEventBusDestinationTypeNames } from 'n8n-workflow'; - -import { RestController, Get, Post, Delete, GlobalScope } 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'; -import { logStreamingLicensedMiddleware } from './middleware/logStreamingEnabled.middleware.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 - // ---------------------------------------- - - @Get('/destination', { middlewares: [logStreamingLicensedMiddleware] }) - @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(); - } - } - - @Post('/destination', { middlewares: [logStreamingLicensedMiddleware] }) - @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'); - } - - @Get('/testmessage', { middlewares: [logStreamingLicensedMiddleware] }) - @GlobalScope('eventBusDestination:test') - async sendTestMessage(req: express.Request): Promise { - if (isWithIdString(req.query)) { - return await this.eventBus.testDestination(req.query.id); - } - return false; - } - - @Delete('/destination', { middlewares: [logStreamingLicensedMiddleware] }) - @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/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/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/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' }); - } -}; 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/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/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/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, '_'); + } } 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/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 + + 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/components/executions/global/GlobalExecutionsListItem.vue b/packages/editor-ui/src/components/executions/global/GlobalExecutionsListItem.vue index 82c690ed4677d..c2049050a7500 100644 --- a/packages/editor-ui/src/components/executions/global/GlobalExecutionsListItem.vue +++ b/packages/editor-ui/src/components/executions/global/GlobalExecutionsListItem.vue @@ -51,7 +51,7 @@ const isRetriable = computed(() => executionHelpers.isExecutionRetriable(props.e const classes = computed(() => { return { [style.executionListItem]: true, - [style[props.execution.status ?? '']]: !!props.execution.status, + [style[props.execution.status]]: true, }; }); 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/components/layouts/ResourcesListLayout.vue b/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue index 0150a84c22d4c..5305935b7c947 100644 --- a/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue +++ b/packages/editor-ui/src/components/layouts/ResourcesListLayout.vue @@ -160,14 +160,14 @@ import { useRoute } from 'vue-router'; // eslint-disable-next-line unused-imports/no-unused-imports, @typescript-eslint/no-unused-vars import type { BaseTextKey } from '@/plugins/i18n'; -export interface IResource { +export type IResource = { id: string; name: string; value: string; updatedAt?: string; createdAt?: string; homeProject?: ProjectSharingData; -} +}; interface IFilters { search: string; @@ -291,11 +291,11 @@ export default defineComponent({ case 'lastUpdated': return props.sortFns.lastUpdated ? props.sortFns.lastUpdated(a, b) - : new Date(b.updatedAt).valueOf() - new Date(a.updatedAt).valueOf(); + : new Date(b.updatedAt ?? '').valueOf() - new Date(a.updatedAt ?? '').valueOf(); case 'lastCreated': return props.sortFns.lastCreated ? props.sortFns.lastCreated(a, b) - : new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf(); + : new Date(b.createdAt ?? '').valueOf() - new Date(a.createdAt ?? '').valueOf(); case 'nameAsc': return props.sortFns.nameAsc ? props.sortFns.nameAsc(a, b) diff --git a/packages/editor-ui/src/composables/__tests__/useCanvasPanning.test.ts b/packages/editor-ui/src/composables/__tests__/useCanvasPanning.test.ts index e45a3e646313e..be826a1b17b0c 100644 --- a/packages/editor-ui/src/composables/__tests__/useCanvasPanning.test.ts +++ b/packages/editor-ui/src/composables/__tests__/useCanvasPanning.test.ts @@ -69,7 +69,7 @@ describe('useCanvasPanning()', () => { const { onMouseDown, onMouseMove, onMouseUp } = useCanvasPanning(elementRef); onMouseDown(new MouseEvent('mousedown', { button: 1 }), true); - onMouseUp(new MouseEvent('mouseup')); + onMouseUp(); expect(removeEventListenerSpy).toHaveBeenCalledWith('mousemove', onMouseMove); }); 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/composables/useCanvasOperations.spec.ts b/packages/editor-ui/src/composables/useCanvasOperations.spec.ts index cb234b4249404..8d19d30b6b06b 100644 --- a/packages/editor-ui/src/composables/useCanvasOperations.spec.ts +++ b/packages/editor-ui/src/composables/useCanvasOperations.spec.ts @@ -9,6 +9,7 @@ import { createPinia, setActivePinia } from 'pinia'; import { createTestNode } from '@/__tests__/mocks'; import type { Connection } from '@vue-flow/core'; import type { IConnection } from 'n8n-workflow'; +import { NodeConnectionType } from 'n8n-workflow'; describe('useCanvasOperations', () => { let workflowsStore: ReturnType; @@ -200,9 +201,9 @@ describe('useCanvasOperations', () => { const connection: Connection = { source: nodeA.id, - sourceHandle: 'outputs/main/0', + sourceHandle: `outputs/${NodeConnectionType.Main}/0`, target: nodeB.id, - targetHandle: 'inputs/main/0', + targetHandle: `inputs/${NodeConnectionType.Main}/0`, }; vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(nodeA).mockReturnValueOnce(nodeB); @@ -211,8 +212,8 @@ describe('useCanvasOperations', () => { expect(addConnectionSpy).toHaveBeenCalledWith({ connection: [ - { index: 0, node: nodeA.name, type: 'main' }, - { index: 0, node: nodeB.name, type: 'main' }, + { index: 0, node: nodeA.name, type: NodeConnectionType.Main }, + { index: 0, node: nodeB.name, type: NodeConnectionType.Main }, ], }); expect(uiStore.stateIsDirty).toBe(true); @@ -269,9 +270,9 @@ describe('useCanvasOperations', () => { const connection: Connection = { source: nodeA.id, - sourceHandle: 'outputs/main/0', + sourceHandle: `outputs/${NodeConnectionType.Main}/0`, target: nodeB.id, - targetHandle: 'inputs/main/0', + targetHandle: `inputs/${NodeConnectionType.Main}/0`, }; vi.spyOn(workflowsStore, 'getNodeById').mockReturnValueOnce(nodeA).mockReturnValueOnce(nodeB); @@ -280,8 +281,8 @@ describe('useCanvasOperations', () => { expect(removeConnectionSpy).toHaveBeenCalledWith({ connection: [ - { index: 0, node: nodeA.name, type: 'main' }, - { index: 0, node: nodeB.name, type: 'main' }, + { index: 0, node: nodeA.name, type: NodeConnectionType.Main }, + { index: 0, node: nodeB.name, type: NodeConnectionType.Main }, ], }); }); @@ -294,8 +295,8 @@ describe('useCanvasOperations', () => { .mockImplementation(() => {}); const connection: [IConnection, IConnection] = [ - { node: 'sourceNode', type: 'type', index: 1 }, - { node: 'targetNode', type: 'type', index: 2 }, + { node: 'sourceNode', type: NodeConnectionType.Main, index: 1 }, + { node: 'targetNode', type: NodeConnectionType.Main, index: 2 }, ]; canvasOperations.revertDeleteConnection(connection); diff --git a/packages/editor-ui/src/composables/useCanvasPanning.ts b/packages/editor-ui/src/composables/useCanvasPanning.ts index b1bf79a02538d..4bf97cc809458 100644 --- a/packages/editor-ui/src/composables/useCanvasPanning.ts +++ b/packages/editor-ui/src/composables/useCanvasPanning.ts @@ -24,7 +24,7 @@ export function useCanvasPanning( /** * Updates the canvas offset position based on the mouse movement */ - function panCanvas(e: MouseEvent) { + function panCanvas(e: MouseEvent | TouchEvent) { const offsetPosition = uiStore.nodeViewOffsetPosition; const [x, y] = getMousePosition(e); diff --git a/packages/editor-ui/src/composables/useExecutionDebugging.ts b/packages/editor-ui/src/composables/useExecutionDebugging.ts index b0f9a60cf9c3b..74966a612fe30 100644 --- a/packages/editor-ui/src/composables/useExecutionDebugging.ts +++ b/packages/editor-ui/src/composables/useExecutionDebugging.ts @@ -15,6 +15,7 @@ import { useSettingsStore } from '@/stores/settings.store'; import { useUIStore } from '@/stores/ui.store'; import { useTelemetry } from './useTelemetry'; import { useRootStore } from '@/stores/n8nRoot.store'; +import { isFullExecutionResponse } from '@/utils/typeGuards'; export const useExecutionDebugging = () => { const telemetry = useTelemetry(); @@ -131,7 +132,7 @@ export const useExecutionDebugging = () => { telemetry.track('User clicked debug execution button', { instance_id: useRootStore().instanceId, - exec_status: execution.status, + exec_status: isFullExecutionResponse(execution) ? execution.status : '', override_pinned_data: pinnableNodes.length === pinnings, all_exec_data_imported: missingNodeNames.length === 0, }); diff --git a/packages/editor-ui/src/composables/useExecutionHelpers.ts b/packages/editor-ui/src/composables/useExecutionHelpers.ts index 47f74ecf6c56c..b39e03c5c1df0 100644 --- a/packages/editor-ui/src/composables/useExecutionHelpers.ts +++ b/packages/editor-ui/src/composables/useExecutionHelpers.ts @@ -56,7 +56,7 @@ export function useExecutionHelpers() { function isExecutionRetriable(execution: ExecutionSummary): boolean { return ( - ['crashed', 'error'].includes(execution.status ?? '') && + ['crashed', 'error'].includes(execution.status) && !execution.retryOf && !execution.retrySuccessId ); diff --git a/packages/editor-ui/src/composables/useNodeHelpers.ts b/packages/editor-ui/src/composables/useNodeHelpers.ts index cf4fd31f47363..003fda4848967 100644 --- a/packages/editor-ui/src/composables/useNodeHelpers.ts +++ b/packages/editor-ui/src/composables/useNodeHelpers.ts @@ -271,7 +271,7 @@ export function useNodeHelpers() { } } - function updateNodeParameterIssues(node: INodeUi, nodeType?: INodeTypeDescription): void { + function updateNodeParameterIssues(node: INodeUi, nodeType?: INodeTypeDescription | null): void { const localNodeType = nodeType ?? nodeTypesStore.getNodeType(node.type, node.typeVersion); if (localNodeType === null) { diff --git a/packages/editor-ui/src/composables/usePushConnection.ts b/packages/editor-ui/src/composables/usePushConnection.ts index 4762b8aba9975..416b21b7f81c2 100644 --- a/packages/editor-ui/src/composables/usePushConnection.ts +++ b/packages/editor-ui/src/composables/usePushConnection.ts @@ -530,6 +530,7 @@ export function usePushConnection({ router }: { router: ReturnType, - connections: IConnections, - copyData?: boolean, -): Workflow { +function getWorkflow(nodes: INodeUi[], connections: IConnections, copyData?: boolean): Workflow { return useWorkflowsStore().getWorkflow(nodes, connections, copyData); } 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/hooks/utils/hooksAddFakeDoorFeatures.ts b/packages/editor-ui/src/hooks/utils/hooksAddFakeDoorFeatures.ts index e768983c90366..814f8679a3d7e 100644 --- a/packages/editor-ui/src/hooks/utils/hooksAddFakeDoorFeatures.ts +++ b/packages/editor-ui/src/hooks/utils/hooksAddFakeDoorFeatures.ts @@ -1,6 +1,7 @@ import { useUIStore } from '@/stores/ui.store'; import type { IFakeDoor } from '@/Interface'; import { FAKE_DOOR_FEATURES } from '@/constants'; +import type { BaseTextKey } from '@/plugins/i18n'; export function compileFakeDoorFeatures(): IFakeDoor[] { const store = useUIStore(); @@ -20,7 +21,7 @@ export function compileFakeDoorFeatures(): IFakeDoor[] { if (loggingFeature) { loggingFeature.actionBoxTitle += '.cloud'; loggingFeature.linkURL += '&edition=cloud'; - loggingFeature.infoText = ''; + loggingFeature.infoText = '' as BaseTextKey; } return fakeDoorFeatures; diff --git a/packages/editor-ui/src/main.ts b/packages/editor-ui/src/main.ts index db8cc15077f7b..9a51382f7e395 100644 --- a/packages/editor-ui/src/main.ts +++ b/packages/editor-ui/src/main.ts @@ -50,7 +50,8 @@ app.mount('#app'); if (!import.meta.env.PROD) { // Make sure that we get all error messages properly displayed // as long as we are not in production mode - window.onerror = (message, source, lineno, colno, error) => { + window.onerror = (message, _source, _lineno, _colno, error) => { + // eslint-disable-next-line @typescript-eslint/no-base-to-string if (message.toString().includes('ResizeObserver')) { // That error can apparently be ignored and can probably // not do anything about it anyway diff --git a/packages/editor-ui/src/mixins/nodeBase.ts b/packages/editor-ui/src/mixins/nodeBase.ts index 2852021bb0c31..58e191177cb00 100644 --- a/packages/editor-ui/src/mixins/nodeBase.ts +++ b/packages/editor-ui/src/mixins/nodeBase.ts @@ -29,6 +29,7 @@ import { useCanvasStore } from '@/stores/canvas.store'; import type { EndpointSpec } from '@jsplumb/common'; import { useDeviceSupport } from 'n8n-design-system'; import type { N8nEndpointLabelLength } from '@/plugins/jsplumb/N8nPlusEndpointType'; +import { isValidNodeConnectionType } from '@/utils/typeGuards'; const createAddInputEndpointSpec = ( connectionName: NodeConnectionType, @@ -119,9 +120,9 @@ export const nodeBase = defineComponent({ }, methods: { __addEndpointTestingData(endpoint: Endpoint, type: string, inputIndex: number) { - if (window?.Cypress && 'canvas' in endpoint.endpoint) { + if (window?.Cypress && 'canvas' in endpoint.endpoint && this.instance) { const canvas = endpoint.endpoint.canvas; - this.instance.setAttribute(canvas, 'data-endpoint-name', this.data.name); + this.instance.setAttribute(canvas, 'data-endpoint-name', this.data?.name ?? ''); this.instance.setAttribute(canvas, 'data-input-index', inputIndex.toString()); this.instance.setAttribute(canvas, 'data-endpoint-type', type); } @@ -216,7 +217,11 @@ export const nodeBase = defineComponent({ spacerIndexes, )[rootTypeIndex]; - const scope = NodeViewUtils.getEndpointScope(inputName as NodeConnectionType); + if (!isValidNodeConnectionType(inputName)) { + return; + } + + const scope = NodeViewUtils.getEndpointScope(inputName); const newEndpointData: EndpointOptions = { uuid: NodeViewUtils.getInputEndpointUUID(this.nodeId, inputName, typeIndex), @@ -252,15 +257,15 @@ export const nodeBase = defineComponent({ }; const endpoint = this.instance?.addEndpoint( - this.$refs[this.data.name] as Element, + this.$refs[this.data?.name ?? ''] as Element, newEndpointData, ) as Endpoint; this.__addEndpointTestingData(endpoint, 'input', typeIndex); - if (inputConfiguration.displayName || nodeTypeData.inputNames?.[i]) { + if (inputConfiguration.displayName ?? nodeTypeData.inputNames?.[i]) { // Apply input names if they got set endpoint.addOverlay( NodeViewUtils.getInputNameOverlay( - inputConfiguration.displayName || nodeTypeData.inputNames[i], + inputConfiguration.displayName ?? nodeTypeData.inputNames?.[i] ?? '', inputName, inputConfiguration.required, ), @@ -288,7 +293,7 @@ export const nodeBase = defineComponent({ // } }); if (sortedInputs.length === 0) { - this.instance.manage(this.$refs[this.data.name] as Element); + this.instance?.manage(this.$refs[this.data?.name ?? ''] as Element); } }, getSpacerIndexes( @@ -349,6 +354,10 @@ export const nodeBase = defineComponent({ [key: string]: number; } = {}; + if (!this.data) { + return; + } + this.outputs = NodeHelpers.getNodeOutputs(this.workflow, this.data, nodeTypeData) || []; // TODO: There are still a lot of references of "main" in NodesView and @@ -380,7 +389,7 @@ export const nodeBase = defineComponent({ const endpointLabelLength = getEndpointLabelLength(maxLabelLength); - this.outputs.forEach((value, i) => { + this.outputs.forEach((_value, i) => { const outputConfiguration = outputConfigurations[i]; const outputName: ConnectionTypes = outputConfiguration.type; @@ -419,7 +428,11 @@ export const nodeBase = defineComponent({ outputsOfSameRootType.length, )[rootTypeIndex]; - const scope = NodeViewUtils.getEndpointScope(outputName as NodeConnectionType); + if (!isValidNodeConnectionType(outputName)) { + return; + } + + const scope = NodeViewUtils.getEndpointScope(outputName); const newEndpointData: EndpointOptions = { uuid: NodeViewUtils.getOutputEndpointUUID(this.nodeId, outputName, typeIndex), @@ -448,13 +461,17 @@ export const nodeBase = defineComponent({ ...this.__getOutputConnectionStyle(outputName, outputConfiguration, nodeTypeData), }; - const endpoint = this.instance.addEndpoint( - this.$refs[this.data.name] as Element, + const endpoint = this.instance?.addEndpoint( + this.$refs[this.data?.name ?? ''] as Element, newEndpointData, ); + if (!endpoint) { + return; + } + this.__addEndpointTestingData(endpoint, 'output', typeIndex); - if (outputConfiguration.displayName) { + if (outputConfiguration.displayName && isValidNodeConnectionType(outputName)) { // Apply output names if they got set const overlaySpec = NodeViewUtils.getOutputNameOverlay( outputConfiguration.displayName, @@ -514,6 +531,10 @@ export const nodeBase = defineComponent({ plusEndpointData.cssClass = `${plusEndpointData.cssClass} ${outputConfiguration?.category}`; } + if (!this.instance || !this.data) { + return; + } + const plusEndpoint = this.instance.addEndpoint( this.$refs[this.data.name] as Element, plusEndpointData, @@ -556,7 +577,7 @@ export const nodeBase = defineComponent({ }; } - if (!Object.values(NodeConnectionType).includes(connectionType as NodeConnectionType)) { + if (!isValidNodeConnectionType(connectionType)) { return {}; } @@ -614,13 +635,13 @@ export const nodeBase = defineComponent({ }; } - if (!Object.values(NodeConnectionType).includes(connectionType as NodeConnectionType)) { + if (!isValidNodeConnectionType(connectionType)) { return {}; } return createSupplementalConnectionType(connectionType); }, - touchEnd(e: MouseEvent) { + touchEnd(_e: MouseEvent) { const deviceSupport = useDeviceSupport(); if (deviceSupport.isTouchDevice) { if (this.uiStore.isActionActive('dragActive')) { @@ -651,7 +672,7 @@ export const nodeBase = defineComponent({ this.$emit('deselectAllNodes'); } - if (this.uiStore.isNodeSelected(this.data.name)) { + if (this.uiStore.isNodeSelected(this.data?.name ?? '')) { this.$emit('deselectNode', this.name); } else { this.$emit('nodeSelected', this.name); diff --git a/packages/editor-ui/src/mixins/userHelpers.ts b/packages/editor-ui/src/mixins/userHelpers.ts index eba9375a5af93..6013154e4fe9f 100644 --- a/packages/editor-ui/src/mixins/userHelpers.ts +++ b/packages/editor-ui/src/mixins/userHelpers.ts @@ -1,7 +1,6 @@ import { defineComponent } from 'vue'; import type { RouteLocation } from 'vue-router'; import { hasPermission } from '@/utils/rbac/permissions'; -import type { RouteConfig } from '@/types/router'; import type { PermissionTypeOptions } from '@/types/rbac'; export const userHelpers = defineComponent({ @@ -16,7 +15,7 @@ export const userHelpers = defineComponent({ return this.canUserAccessRoute(this.$route); }, - canUserAccessRoute(route: RouteLocation & RouteConfig) { + canUserAccessRoute(route: RouteLocation) { const middleware = route.meta?.middleware; const middlewareOptions = route.meta?.middlewareOptions; 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); diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/mock.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/mock.ts index 5218ea2c3a8db..20268b67ebffd 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/mock.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/mock.ts @@ -6,6 +6,7 @@ import type { IExecuteData, INodeTypeData, } from 'n8n-workflow'; +import { NodeConnectionType } from 'n8n-workflow'; import { WorkflowDataProxy } from 'n8n-workflow'; import { createTestWorkflowObject } from '@/__tests__/mocks'; @@ -91,7 +92,7 @@ const connections: IConnections = { [ { node: 'Function', - type: 'main', + type: NodeConnectionType.Main, index: 0, }, ], @@ -102,7 +103,7 @@ const connections: IConnections = { [ { node: 'Rename', - type: 'main', + type: NodeConnectionType.Main, index: 0, }, ], @@ -113,7 +114,7 @@ const connections: IConnections = { [ { node: 'End', - type: 'main', + type: NodeConnectionType.Main, index: 0, }, ], diff --git a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/variables.completions.test.ts b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/variables.completions.test.ts index 681709737fcdf..09864a7bd3fbc 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/__tests__/variables.completions.test.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/__tests__/variables.completions.test.ts @@ -14,8 +14,8 @@ beforeEach(() => { describe('variablesCompletions', () => { test('should return completions for $vars prefix', () => { environmentsStore.variables = [ - { key: 'VAR1', value: 'Value1', id: 1 }, - { key: 'VAR2', value: 'Value2', id: 2 }, + { key: 'VAR1', value: 'Value1', id: '1' }, + { key: 'VAR2', value: 'Value2', id: '2' }, ]; const state = EditorState.create({ doc: '$vars.', selection: { anchor: 6 } }); @@ -39,7 +39,7 @@ describe('variablesCompletions', () => { }); test('should escape special characters in matcher', () => { - environmentsStore.variables = [{ key: 'VAR1', value: 'Value1', id: 1 }]; + environmentsStore.variables = [{ key: 'VAR1', value: 'Value1', id: '1' }]; const state = EditorState.create({ doc: '$vars.', selection: { anchor: 6 } }); const context = new CompletionContext(state, 6, true); @@ -49,7 +49,7 @@ describe('variablesCompletions', () => { }); test('should return completions for custom matcher', () => { - environmentsStore.variables = [{ key: 'VAR1', value: 'Value1', id: 1 }]; + environmentsStore.variables = [{ key: 'VAR1', value: 'Value1', id: '1' }]; const state = EditorState.create({ doc: '$custom.', selection: { anchor: 8 } }); const context = new CompletionContext(state, 8, true); 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; }, }, diff --git a/packages/editor-ui/src/plugins/components.ts b/packages/editor-ui/src/plugins/components.ts index 24ed30d3b7434..d87d7f0ac6fdb 100644 --- a/packages/editor-ui/src/plugins/components.ts +++ b/packages/editor-ui/src/plugins/components.ts @@ -9,7 +9,7 @@ import EnterpriseEdition from '@/components/EnterpriseEdition.ee.vue'; import RBAC from '@/components/RBAC.vue'; import ParameterInputList from '@/components/ParameterInputList.vue'; -export const GlobalComponentsPlugin: Plugin<{}> = { +export const GlobalComponentsPlugin: Plugin = { install(app) { const messageService = useMessage(); @@ -18,7 +18,7 @@ export const GlobalComponentsPlugin: Plugin<{}> = { app.component('ParameterInputList', ParameterInputList); app.use(ElementPlus); - app.use(N8nPlugin); + app.use(N8nPlugin, {}); // app.use(ElLoading); // app.use(ElNotification); diff --git a/packages/editor-ui/src/plugins/connectors/N8nCustomConnector.ts b/packages/editor-ui/src/plugins/connectors/N8nCustomConnector.ts index 34561d23cdd68..07bd3609d0eb8 100644 --- a/packages/editor-ui/src/plugins/connectors/N8nCustomConnector.ts +++ b/packages/editor-ui/src/plugins/connectors/N8nCustomConnector.ts @@ -169,7 +169,7 @@ export class N8nConnector extends AbstractConnector { targetGap: number; - overrideTargetEndpoint: Endpoint | null; + overrideTargetEndpoint: Endpoint; getEndpointOffset?: (e: Endpoint) => number | null; @@ -517,7 +517,7 @@ export class N8nConnector extends AbstractConnector { } resetTargetEndpoint() { - this.overrideTargetEndpoint = null; + this.overrideTargetEndpoint = null as unknown as Endpoint; } _computeBezier(paintInfo: N8nConnectorPaintGeometry) { diff --git a/packages/editor-ui/src/plugins/directives.ts b/packages/editor-ui/src/plugins/directives.ts index 9302499e3144b..8b092b4c4f899 100644 --- a/packages/editor-ui/src/plugins/directives.ts +++ b/packages/editor-ui/src/plugins/directives.ts @@ -2,7 +2,7 @@ import type { Plugin } from 'vue'; import VueTouchEvents from 'vue3-touch-events'; import { vOnClickOutside } from '@vueuse/components'; -export const GlobalDirectivesPlugin: Plugin<{}> = { +export const GlobalDirectivesPlugin: Plugin = { install(app) { app.use(VueTouchEvents); app.directive('on-click-outside', vOnClickOutside); diff --git a/packages/editor-ui/src/plugins/i18n/index.ts b/packages/editor-ui/src/plugins/i18n/index.ts index 36df6c6c8b402..227c0d3a598de 100644 --- a/packages/editor-ui/src/plugins/i18n/index.ts +++ b/packages/editor-ui/src/plugins/i18n/index.ts @@ -1,7 +1,7 @@ import type { Plugin } from 'vue'; import axios from 'axios'; import { createI18n } from 'vue-i18n'; -import { locale } from 'n8n-design-system'; +import { locale, type N8nLocaleTranslateFn } from 'n8n-design-system'; import type { INodeProperties, INodePropertyCollection, INodePropertyOptions } from 'n8n-workflow'; import type { INodeTranslationHeaders } from '@/Interface'; @@ -22,6 +22,8 @@ export const i18nInstance = createI18n({ messages: { en: englishBaseText }, }); +type BaseTextOptions = { adjustToNumber?: number; interpolate?: Record }; + export class I18nClass { private baseTextCache = new Map(); @@ -48,10 +50,7 @@ export class I18nClass { /** * Render a string of base text, i.e. a string with a fixed path to the localized value. Optionally allows for [interpolation](https://kazupon.github.io/vue-i18n/guide/formatting.html#named-formatting) when the localized value contains a string between curly braces. */ - baseText( - key: BaseTextKey, - options?: { adjustToNumber?: number; interpolate?: Record }, - ): string { + baseText(key: BaseTextKey, options?: BaseTextOptions): string { // Create a unique cache key const cacheKey = `${key}-${JSON.stringify(options)}`; @@ -438,11 +437,10 @@ export function addHeaders(headers: INodeTranslationHeaders, language: string) { export const i18n: I18nClass = new I18nClass(); -export const I18nPlugin: Plugin<{}> = { +export const I18nPlugin: Plugin = { async install(app) { - locale.i18n((key: string, options?: { interpolate: Record }) => - i18nInstance.global.t(key, options?.interpolate || {}), - ); + locale.i18n(((key: string, options?: BaseTextOptions) => + i18nInstance.global.t(key, options?.interpolate ?? {})) as N8nLocaleTranslateFn); app.config.globalProperties.$locale = i18n; 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/icons/index.ts b/packages/editor-ui/src/plugins/icons/index.ts index da674121b687d..4f86547847a78 100644 --- a/packages/editor-ui/src/plugins/icons/index.ts +++ b/packages/editor-ui/src/plugins/icons/index.ts @@ -166,7 +166,7 @@ function addIcon(icon: IconDefinition) { library.add(icon); } -export const FontAwesomePlugin: Plugin<{}> = { +export const FontAwesomePlugin: Plugin = { install: (app) => { addIcon(faAngleDoubleLeft); addIcon(faAngleDown); diff --git a/packages/editor-ui/src/plugins/jsplumb/N8nPlusEndpointType.ts b/packages/editor-ui/src/plugins/jsplumb/N8nPlusEndpointType.ts index 5e2fd290a611e..fc19318549d9c 100644 --- a/packages/editor-ui/src/plugins/jsplumb/N8nPlusEndpointType.ts +++ b/packages/editor-ui/src/plugins/jsplumb/N8nPlusEndpointType.ts @@ -32,8 +32,6 @@ export class N8nPlusEndpoint extends EndpointRepresentation = { +export const JsPlumbPlugin: Plugin = { install: () => { - Connectors.register(N8nConnector.type, N8nConnector); + Connectors.register(N8nConnector.type, N8nConnector as Constructable); N8nPlusEndpointRenderer.register(); EndpointFactory.registerHandler(N8nPlusEndpointHandler); diff --git a/packages/editor-ui/src/plugins/telemetry/index.ts b/packages/editor-ui/src/plugins/telemetry/index.ts index 4cb2c1fb2e004..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(); } } @@ -120,7 +120,7 @@ export class Telemetry { } } - page(route: Route) { + page(route: RouteLocation) { if (this.rudderStack) { if (route.path === this.previousPath) { // avoid duplicate requests query is changed for example on search page @@ -128,8 +128,8 @@ export class Telemetry { } this.previousPath = route.path; - const pageName = route.name; - let properties: { [key: string]: string } = {}; + const pageName = String(route.name); + let properties: Record = {}; if (route.meta?.telemetry && typeof route.meta.telemetry.getProperties === 'function') { properties = route.meta.telemetry.getProperties(route); } @@ -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); @@ -330,7 +337,7 @@ export class Telemetry { export const telemetry = new Telemetry(); -export const TelemetryPlugin: Plugin<{}> = { +export const TelemetryPlugin: Plugin = { install(app) { app.config.globalProperties.$telemetry = telemetry; }, 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/router.ts b/packages/editor-ui/src/router.ts index 3b91acafea31c..74c15162ef8f7 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -14,7 +14,7 @@ import { useSSOStore } from '@/stores/sso.store'; import { EnterpriseEditionFeature, VIEWS, EDITABLE_CANVAS_VIEWS } from '@/constants'; import { useTelemetry } from '@/composables/useTelemetry'; import { middleware } from '@/utils/rbac/middleware'; -import type { RouteConfig, RouterMiddleware } from '@/types/router'; +import type { RouterMiddleware } from '@/types/router'; import { initializeCore } from '@/init'; import { tryToParseNumber } from '@/utils/typesUtils'; import { projectsRoutes } from '@/routes/projects.routes'; @@ -60,17 +60,17 @@ const WorkerView = async () => await import('./views/WorkerView.vue'); const WorkflowHistory = async () => await import('@/views/WorkflowHistory.vue'); const WorkflowOnboardingView = async () => await import('@/views/WorkflowOnboardingView.vue'); -function getTemplatesRedirect(defaultRedirect: VIEWS[keyof VIEWS]) { +function getTemplatesRedirect(defaultRedirect: VIEWS[keyof VIEWS]): { name: string } | false { const settingsStore = useSettingsStore(); const isTemplatesEnabled: boolean = settingsStore.isTemplatesEnabled; if (!isTemplatesEnabled) { - return { name: defaultRedirect || VIEWS.NOT_FOUND }; + return { name: `${defaultRedirect}` || VIEWS.NOT_FOUND }; } return false; } -export const routes = [ +export const routes: RouteRecordRaw[] = [ { path: '/', redirect: '/home/workflows', @@ -742,7 +742,7 @@ export const routes = [ }, }, }, -] as Array; +]; function withCanvasReadOnlyMeta(route: RouteRecordRaw) { if (!route.meta) { @@ -759,7 +759,7 @@ function withCanvasReadOnlyMeta(route: RouteRecordRaw) { const router = createRouter({ history: createWebHistory(import.meta.env.DEV ? '/' : window.BASE_PATH ?? '/'), - scrollBehavior(to: RouteLocationNormalized & RouteConfig, from, savedPosition) { + scrollBehavior(to: RouteLocationNormalized, _, savedPosition) { // saved position == null means the page is NOT visited from history (back button) if (savedPosition === null && to.name === VIEWS.TEMPLATES && to.meta?.setScrollPosition) { // for templates view, reset scroll position in this case @@ -769,7 +769,7 @@ const router = createRouter({ routes: routes.map(withCanvasReadOnlyMeta), }); -router.beforeEach(async (to: RouteLocationNormalized & RouteConfig, from, next) => { +router.beforeEach(async (to: RouteLocationNormalized, from, next) => { try { /** * Initialize application core diff --git a/packages/editor-ui/src/jsplumb.d.ts b/packages/editor-ui/src/shims-jsplumb.d.ts similarity index 69% rename from packages/editor-ui/src/jsplumb.d.ts rename to packages/editor-ui/src/shims-jsplumb.d.ts index 05d2c328ecef3..1bd6f5aeddc3e 100644 --- a/packages/editor-ui/src/jsplumb.d.ts +++ b/packages/editor-ui/src/shims-jsplumb.d.ts @@ -1,5 +1,12 @@ -import type { Connection, Endpoint, EndpointRepresentation, AbstractConnector, Overlay } from '@jsplumb/core'; +import type { + Connection, + Endpoint, + EndpointRepresentation, + AbstractConnector, + Overlay, +} from '@jsplumb/core'; import type { NodeConnectionType } from 'n8n-workflow'; +import type { N8nEndpointLabelLength } from '@/plugins/jsplumb/N8nPlusEndpointType'; declare module '@jsplumb/core' { interface EndpointRepresentation { @@ -27,8 +34,9 @@ declare module '@jsplumb/core' { nodeName: string; nodeId: string; index: number; + nodeType?: string; totalEndpoints: number; - endpointLabelLength: number; + endpointLabelLength?: N8nEndpointLabelLength; }; - }; + } } diff --git a/packages/editor-ui/src/v3-infinite-loading.d.ts b/packages/editor-ui/src/shims-modules.d.ts similarity index 61% rename from packages/editor-ui/src/v3-infinite-loading.d.ts rename to packages/editor-ui/src/shims-modules.d.ts index 1fa589586f950..7191a5a3ab68b 100644 --- a/packages/editor-ui/src/v3-infinite-loading.d.ts +++ b/packages/editor-ui/src/shims-modules.d.ts @@ -1,3 +1,21 @@ +/** + * Modules + */ + +declare module 'vue-agile'; + +/** + * File types + */ + +declare module '*.json'; +declare module '*.svg'; +declare module '*.png'; +declare module '*.jpg'; +declare module '*.jpeg'; +declare module '*.gif'; +declare module '*.webp'; + declare module 'v3-infinite-loading' { import { Plugin, DefineComponent } from 'vue'; diff --git a/packages/editor-ui/src/shims-vue.d.ts b/packages/editor-ui/src/shims-vue.d.ts index 6d9f9c73096bd..99bb947c46490 100644 --- a/packages/editor-ui/src/shims-vue.d.ts +++ b/packages/editor-ui/src/shims-vue.d.ts @@ -1,6 +1,16 @@ +import 'vue-router'; import type { I18nClass } from '@/plugins/i18n'; -import type { Route } from 'vue-router'; +import type { Route, RouteLocation } from 'vue-router'; import type { Telemetry } from '@/plugins/telemetry'; +import type { VIEWS } from '@/constants'; +import type { IPermissions } from '@/Interface'; +import type { MiddlewareOptions, RouterMiddlewareType } from '@/types/router'; + +export {}; + +/** + * @docs https://vuejs.org/guide/typescript/options-api.html#augmenting-global-properties + */ declare module 'vue' { interface ComponentCustomOptions { @@ -17,6 +27,26 @@ declare module 'vue' { } /** - * @docs https://vuejs.org/guide/typescript/options-api.html#augmenting-global-properties + * @docs https://router.vuejs.org/guide/advanced/meta */ -export {}; + +declare module 'vue-router' { + interface RouteMeta { + nodeView?: boolean; + templatesEnabled?: boolean; + getRedirect?: + | (() => { name: string } | false) + | ((defaultRedirect: VIEWS[keyof VIEWS]) => { name: string } | false); + permissions?: IPermissions; + middleware?: RouterMiddlewareType[]; + middlewareOptions?: Partial; + telemetry?: { + disabled?: true; + pageCategory?: string; + getProperties?: (route: RouteLocation) => Record; + }; + scrollOffset?: number; + setScrollPosition?: (position: number) => void; + readOnlyCanvas?: boolean; + } +} diff --git a/packages/editor-ui/src/shims.d.ts b/packages/editor-ui/src/shims.d.ts index 643d63b91fa87..8121b15d8998c 100644 --- a/packages/editor-ui/src/shims.d.ts +++ b/packages/editor-ui/src/shims.d.ts @@ -1,11 +1,8 @@ -import { VNode, ComponentPublicInstance } from 'vue'; -import { PartialDeep } from 'type-fest'; -import { ExternalHooks } from '@/types/externalHooks'; +import type { VNode, ComponentPublicInstance } from 'vue'; +import type { PartialDeep } from 'type-fest'; +import type { ExternalHooks } from '@/types/externalHooks'; -declare module 'markdown-it-link-attributes'; -declare module 'markdown-it-emoji'; -declare module 'markdown-it-task-lists'; -declare module 'vue-agile'; +export {}; declare global { interface ImportMeta { @@ -37,10 +34,3 @@ declare global { findLast(predicate: (value: T, index: number, obj: T[]) => unknown, thisArg?: any): T; } } - -declare module '*.svg'; -declare module '*.png'; -declare module '*.jpg'; -declare module '*.jpeg'; -declare module '*.gif'; -declare module '*.webp'; diff --git a/packages/editor-ui/src/stores/canvas.store.ts b/packages/editor-ui/src/stores/canvas.store.ts index d3fb870901ef7..d022e3bce8b2c 100644 --- a/packages/editor-ui/src/stores/canvas.store.ts +++ b/packages/editor-ui/src/stores/canvas.store.ts @@ -19,6 +19,7 @@ import { MANUAL_TRIGGER_NODE_TYPE, START_NODE_TYPE } from '@/constants'; import type { BeforeStartEventParams, BrowserJsPlumbInstance, + ConstrainFunction, DragStopEventParams, } from '@jsplumb/browser-ui'; import { newInstance } from '@jsplumb/browser-ui'; @@ -307,14 +308,14 @@ export const useCanvasStore = defineStore('canvas', () => { filter: '.node-description, .node-description .node-name, .node-description .node-subtitle', }, }); - jsPlumbInstanceRef.value?.setDragConstrainFunction((pos: PointXY) => { + jsPlumbInstanceRef.value?.setDragConstrainFunction(((pos: PointXY) => { const isReadOnly = uiStore.isReadOnlyView; if (isReadOnly) { // Do not allow to move nodes in readOnly mode return null; } return pos; - }); + }) as ConstrainFunction); } const jsPlumbInstance = computed(() => jsPlumbInstanceRef.value as BrowserJsPlumbInstance); diff --git a/packages/editor-ui/src/stores/credentials.store.ts b/packages/editor-ui/src/stores/credentials.store.ts index aa3c4d747d7ec..44454c0f343ad 100644 --- a/packages/editor-ui/src/stores/credentials.store.ts +++ b/packages/editor-ui/src/stores/credentials.store.ts @@ -236,9 +236,6 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, { }; } }, - enableOAuthCredential(credential: ICredentialsResponse): void { - // enable oauth event to track change between modals - }, async fetchCredentialTypes(forceFetch: boolean): Promise { if (this.allCredentialTypes.length > 0 && !forceFetch) { return; diff --git a/packages/editor-ui/src/stores/executions.store.ts b/packages/editor-ui/src/stores/executions.store.ts index 95b4247f53dcf..bdbe4b32ceb9f 100644 --- a/packages/editor-ui/src/stores/executions.store.ts +++ b/packages/editor-ui/src/stores/executions.store.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia'; import { computed, ref } from 'vue'; -import type { ExecutionStatus, IDataObject, ExecutionSummary } from 'n8n-workflow'; +import type { IDataObject, ExecutionSummary } from 'n8n-workflow'; import type { ExecutionFilterType, ExecutionsQueryFilter, @@ -83,7 +83,6 @@ export const useExecutionsStore = defineStore('executions', () => { function addExecution(execution: ExecutionSummary) { executionsById.value[execution.id] = { ...execution, - status: execution.status ?? getExecutionStatus(execution), mode: execution.mode, }; } @@ -91,7 +90,6 @@ export const useExecutionsStore = defineStore('executions', () => { function addCurrentExecution(execution: ExecutionSummary) { currentExecutionsById.value[execution.id] = { ...execution, - status: execution.status ?? getExecutionStatus(execution), mode: execution.mode, }; } @@ -113,24 +111,6 @@ export const useExecutionsStore = defineStore('executions', () => { await startAutoRefreshInterval(workflowId); } - function getExecutionStatus(execution: ExecutionSummary): ExecutionStatus { - if (execution.status) { - return execution.status; - } else { - if (execution.waitTill) { - return 'waiting'; - } else if (execution.stoppedAt === undefined) { - return 'running'; - } else if (execution.finished) { - return 'success'; - } else if (execution.stoppedAt !== null) { - return 'error'; - } else { - return 'unknown'; - } - } - } - async function fetchExecutions( filter = executionsFilters.value, lastId?: string, @@ -274,7 +254,6 @@ export const useExecutionsStore = defineStore('executions', () => { activeExecution, fetchExecutions, fetchExecution, - getExecutionStatus, autoRefresh, autoRefreshTimeout, startAutoRefreshInterval, diff --git a/packages/editor-ui/src/stores/logStreaming.store.ts b/packages/editor-ui/src/stores/logStreaming.store.ts index 1ebe04bfecca8..6eb2573cbe3dd 100644 --- a/packages/editor-ui/src/stores/logStreaming.store.ts +++ b/packages/editor-ui/src/stores/logStreaming.store.ts @@ -201,7 +201,7 @@ export const useLogStreamingStore = defineStore('logStreaming', { return false; } }, - async sendTestMessage(destination: MessageEventBusDestinationOptions) { + async sendTestMessage(destination: MessageEventBusDestinationOptions): Promise { if (!hasDestinationId(destination)) { return false; } diff --git a/packages/editor-ui/src/stores/ndv.store.ts b/packages/editor-ui/src/stores/ndv.store.ts index 1073cc44ff559..0c5ae38685f15 100644 --- a/packages/editor-ui/src/stores/ndv.store.ts +++ b/packages/editor-ui/src/stores/ndv.store.ts @@ -233,6 +233,7 @@ export const useNDVStore = defineStore(STORES.NDV, { isDragging: false, type: '', data: '', + dimensions: null, activeTarget: null, }; }, 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/posthog.store.ts b/packages/editor-ui/src/stores/posthog.store.ts index 8596c142c163d..002666868874b 100644 --- a/packages/editor-ui/src/stores/posthog.store.ts +++ b/packages/editor-ui/src/stores/posthog.store.ts @@ -155,7 +155,7 @@ export const usePostHog = defineStore('posthog', () => { trackExperimentsDebounced(featureFlags.value); } else { // depend on client side evaluation if serverside evaluation fails - window.posthog?.onFeatureFlags?.((keys: string[], map: FeatureFlags) => { + window.posthog?.onFeatureFlags?.((_, map: FeatureFlags) => { featureFlags.value = map; // must be debounced because it is called multiple times by posthog diff --git a/packages/editor-ui/src/stores/rbac.store.ts b/packages/editor-ui/src/stores/rbac.store.ts index 11a17846f1534..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: {}, @@ -32,6 +31,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => { license: {}, logStreaming: {}, saml: {}, + securityAudit: {}, }); function addGlobalRole(role: IRole) { diff --git a/packages/editor-ui/src/stores/settings.store.ts b/packages/editor-ui/src/stores/settings.store.ts index a1c4c97a6edbd..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, @@ -274,7 +266,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, { this.setSettings(settings); this.settings.communityNodesEnabled = settings.communityNodesEnabled; - this.setAllowedModules(settings.allowedModules as { builtIn?: string; external?: string }); + this.setAllowedModules(settings.allowedModules); this.setSaveDataErrorExecution(settings.saveDataErrorExecution); this.setSaveDataSuccessExecution(settings.saveDataSuccessExecution); this.setSaveManualExecutions(settings.saveManualExecutions); @@ -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/templates.store.ts b/packages/editor-ui/src/stores/templates.store.ts index c67a603aea92d..64542b404b828 100644 --- a/packages/editor-ui/src/stores/templates.store.ts +++ b/packages/editor-ui/src/stores/templates.store.ts @@ -63,7 +63,7 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, { return (id: string): null | ITemplatesCollection => this.collections[id]; }, getCategoryById() { - return (id: string): null | ITemplatesCategory => this.categories[id]; + return (id: string): null | ITemplatesCategory => this.categories[id as unknown as number]; }, getSearchedCollections() { return (query: ITemplatesQuery) => { @@ -121,7 +121,7 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, { * Constructs URLSearchParams object based on the default parameters for the template repository * and provided additional parameters */ - websiteTemplateRepositoryParameters(roleOverride?: string) { + websiteTemplateRepositoryParameters(_roleOverride?: string) { const rootStore = useRootStore(); const userStore = useUsersStore(); const workflowsStore = useWorkflowsStore(); @@ -131,8 +131,12 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, { utm_n8n_version: rootStore.versionCli, utm_awc: String(workflowsStore.activeWorkflows.length), }; - const userRole: string | undefined = - userStore.currentUserCloudInfo?.role ?? userStore.currentUser?.personalizationAnswers?.role; + const userRole: string | null | undefined = + userStore.currentUserCloudInfo?.role ?? + (userStore.currentUser?.personalizationAnswers && + 'role' in userStore.currentUser.personalizationAnswers + ? userStore.currentUser.personalizationAnswers.role + : undefined); if (userRole) { defaultParameters.utm_user_role = userRole; 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/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, diff --git a/packages/editor-ui/src/types/router.ts b/packages/editor-ui/src/types/router.ts index 15d1e120db291..b4205ebec0a9e 100644 --- a/packages/editor-ui/src/types/router.ts +++ b/packages/editor-ui/src/types/router.ts @@ -2,9 +2,7 @@ import type { NavigationGuardNext, NavigationGuardWithThis, RouteLocationNormalized, - RouteLocation, } from 'vue-router'; -import type { IPermissions } from '@/Interface'; import type { AuthenticatedPermissionOptions, CustomPermissionOptions, @@ -32,24 +30,6 @@ export type MiddlewareOptions = { role: RolePermissionOptions; }; -export interface RouteConfig { - meta: { - nodeView?: boolean; - templatesEnabled?: boolean; - getRedirect?: () => { name: string } | false; - permissions?: IPermissions; - middleware?: RouterMiddlewareType[]; - middlewareOptions?: Partial; - telemetry?: { - disabled?: true; - getProperties: (route: RouteLocation) => object; - }; - scrollOffset?: number; - setScrollPosition?: (position: number) => void; - readOnlyCanvas?: boolean; - }; -} - export type RouterMiddlewareReturnType = ReturnType>; export interface RouterMiddleware { 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/apiUtils.ts b/packages/editor-ui/src/utils/apiUtils.ts index 5c66c87daefae..686d4a27dbe1f 100644 --- a/packages/editor-ui/src/utils/apiUtils.ts +++ b/packages/editor-ui/src/utils/apiUtils.ts @@ -175,11 +175,9 @@ export async function patch( * * @param {IExecutionFlattedResponse} fullExecutionData The data to unflatten */ -export function unflattenExecutionData( - fullExecutionData: IExecutionFlattedResponse, -): Omit { +export function unflattenExecutionData(fullExecutionData: IExecutionFlattedResponse) { // Unflatten the data - const returnData: Omit = { + const returnData: IExecutionResponse = { ...fullExecutionData, workflowData: fullExecutionData.workflowData, data: parse(fullExecutionData.data), 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}`; diff --git a/packages/editor-ui/src/utils/nodeTypesUtils.ts b/packages/editor-ui/src/utils/nodeTypesUtils.ts index 16ef6db925dcc..6f319418c60c8 100644 --- a/packages/editor-ui/src/utils/nodeTypesUtils.ts +++ b/packages/editor-ui/src/utils/nodeTypesUtils.ts @@ -5,6 +5,7 @@ import type { ITemplatesNode, IVersionNode, NodeAuthenticationOption, + SimplifiedNodeType, } from '@/Interface'; import { CORE_NODES_CATEGORY, @@ -451,21 +452,21 @@ export const getThemedValue = ( }; export const getNodeIcon = ( - nodeType: INodeTypeDescription | IVersionNode, + nodeType: INodeTypeDescription | SimplifiedNodeType | IVersionNode, theme: AppliedThemeOption = 'light', ): string | null => { return getThemedValue(nodeType.icon, theme); }; export const getNodeIconUrl = ( - nodeType: INodeTypeDescription | IVersionNode, + nodeType: INodeTypeDescription | SimplifiedNodeType | IVersionNode, theme: AppliedThemeOption = 'light', ): string | null => { return getThemedValue(nodeType.iconUrl, theme); }; export const getBadgeIconUrl = ( - nodeType: INodeTypeDescription, + nodeType: INodeTypeDescription | SimplifiedNodeType, theme: AppliedThemeOption = 'light', ): string | null => { return getThemedValue(nodeType.badgeIconUrl, theme); diff --git a/packages/editor-ui/src/utils/nodeViewUtils.ts b/packages/editor-ui/src/utils/nodeViewUtils.ts index d8491765f2edf..e268d9ae570aa 100644 --- a/packages/editor-ui/src/utils/nodeViewUtils.ts +++ b/packages/editor-ui/src/utils/nodeViewUtils.ts @@ -1,6 +1,6 @@ import { isNumber, isValidNodeConnectionType } from '@/utils/typeGuards'; import { NODE_OUTPUT_DEFAULT_KEY, STICKY_NODE_TYPE } from '@/constants'; -import type { EndpointMeta, EndpointStyle, IBounds, INodeUi, XYPosition } from '@/Interface'; +import type { EndpointStyle, IBounds, INodeUi, XYPosition } from '@/Interface'; import type { ArrayAnchorSpec, ConnectorSpec, OverlaySpec, PaintStyle } from '@jsplumb/common'; import type { Connection, Endpoint, SelectOptions } from '@jsplumb/core'; import { N8nConnector } from '@/plugins/connectors/N8nCustomConnector'; @@ -73,7 +73,7 @@ export const CONNECTOR_FLOWCHART_TYPE: ConnectorSpec = { alwaysRespectStubs: false, loopbackVerticalLength: NODE_SIZE + GRID_SIZE, // height of vertical segment when looping loopbackMinimum: LOOPBACK_MINIMUM, // minimum length before flowchart loops around - getEndpointOffset(endpoint: Endpoint & EndpointMeta) { + getEndpointOffset(endpoint: Endpoint) { const indexOffset = 10; // stub offset between different endpoints of same node const index = endpoint?.__meta ? endpoint.__meta.index : 0; const totalEndpoints = endpoint?.__meta ? endpoint.__meta.totalEndpoints : 0; @@ -320,7 +320,7 @@ export const getOutputNameOverlay = ( options: { id: OVERLAY_OUTPUT_NAME_LABEL, visible: true, - create: (ep: Endpoint & EndpointMeta) => { + create: (ep: Endpoint) => { const label = document.createElement('div'); label.innerHTML = labelText; label.classList.add('node-output-endpoint-label'); @@ -1120,7 +1120,7 @@ export const getPlusEndpoint = ( ): Endpoint | undefined => { const endpoints = getJSPlumbEndpoints(node, instance); return endpoints.find( - (endpoint: Endpoint & EndpointMeta) => + (endpoint: Endpoint) => endpoint.endpoint.type === 'N8nPlus' && endpoint?.__meta?.index === outputIndex, ); }; diff --git a/packages/editor-ui/src/utils/templates/__tests__/templateTransforms.test.ts b/packages/editor-ui/src/utils/templates/__tests__/templateTransforms.test.ts index d91bb9b66f1fe..117c955ef20b0 100644 --- a/packages/editor-ui/src/utils/templates/__tests__/templateTransforms.test.ts +++ b/packages/editor-ui/src/utils/templates/__tests__/templateTransforms.test.ts @@ -11,6 +11,7 @@ describe('templateTransforms', () => { getNodeType: vitest.fn(), }; const node = newWorkflowTemplateNode({ + id: 'twitter', type: 'n8n-nodes-base.twitter', credentials: { twitterOAuth1Api: 'old1', @@ -40,6 +41,7 @@ describe('templateTransforms', () => { getNodeType: vitest.fn(), }; const node = newWorkflowTemplateNode({ + id: 'twitter', type: 'n8n-nodes-base.twitter', }); const toReplaceWith = { diff --git a/packages/editor-ui/src/utils/testData/templateTestData.ts b/packages/editor-ui/src/utils/testData/templateTestData.ts index 28a3c83952662..255104459e5c7 100644 --- a/packages/editor-ui/src/utils/testData/templateTestData.ts +++ b/packages/editor-ui/src/utils/testData/templateTestData.ts @@ -7,6 +7,7 @@ export const newWorkflowTemplateNode = ({ type, ...optionalOpts }: Pick & + Pick & Partial): IWorkflowTemplateNode => ({ type, name: faker.commerce.productName(), @@ -306,7 +307,6 @@ export const fullSaveEmailAttachmentsToNextCloudTemplate = { export const fullCreateApiEndpointTemplate = { id: 1750, name: 'Creating an API endpoint', - recentViews: 9899, totalViews: 13265, createdAt: '2022-07-06T14:45:19.659Z', description: @@ -393,7 +393,6 @@ export const fullCreateApiEndpointTemplate = { }, }, }, - lastUpdatedBy: 1, workflowInfo: { nodeCount: 2, nodeTypes: {}, diff --git a/packages/editor-ui/src/utils/typeGuards.ts b/packages/editor-ui/src/utils/typeGuards.ts index 90268b88a6c69..9cff433220d80 100644 --- a/packages/editor-ui/src/utils/typeGuards.ts +++ b/packages/editor-ui/src/utils/typeGuards.ts @@ -5,7 +5,7 @@ import type { TriggerPanelDefinition, } from 'n8n-workflow'; import { nodeConnectionTypes } from 'n8n-workflow'; -import type { ICredentialsResponse, NewCredentialsModal } from '@/Interface'; +import type { IExecutionResponse, ICredentialsResponse, NewCredentialsModal } from '@/Interface'; import type { jsPlumbDOMElement } from '@jsplumb/browser-ui'; import type { Connection } from '@jsplumb/core'; @@ -73,3 +73,9 @@ export function isTriggerPanelObject( ): triggerPanel is TriggerPanelDefinition { return triggerPanel !== undefined && typeof triggerPanel === 'object' && triggerPanel !== null; } + +export function isFullExecutionResponse( + execution: IExecutionResponse | null, +): execution is IExecutionResponse { + return !!execution && 'status' in execution; +} diff --git a/packages/editor-ui/src/views/CredentialsView.vue b/packages/editor-ui/src/views/CredentialsView.vue index 7f22979a0424a..b2322f2226aab 100644 --- a/packages/editor-ui/src/views/CredentialsView.vue +++ b/packages/editor-ui/src/views/CredentialsView.vue @@ -63,6 +63,7 @@ import type { ICredentialsResponse, ICredentialTypeMap } from '@/Interface'; import { defineComponent } from 'vue'; +import type { IResource } from '@/components/layouts/ResourcesListLayout.vue'; import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue'; import CredentialCard from '@/components/CredentialCard.vue'; import type { ICredentialType } from 'n8n-workflow'; @@ -106,8 +107,14 @@ export default defineComponent({ useExternalSecretsStore, useProjectsStore, ), - allCredentials(): ICredentialsResponse[] { - return this.credentialsStore.allCredentials; + allCredentials(): IResource[] { + return this.credentialsStore.allCredentials.map((credential) => ({ + id: credential.id, + name: credential.name, + value: '', + updatedAt: credential.updatedAt, + createdAt: credential.createdAt, + })); }, allCredentialTypes(): ICredentialType[] { return this.credentialsStore.allCredentialTypes; diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 63fcd797c15a3..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({ @@ -1572,8 +1574,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; } @@ -1997,12 +2000,14 @@ export default defineComponent({ void this.getNodesToSave(nodes).then((data) => { const workflowToCopy: IWorkflowToShare = { meta: { - ...(this.workflowsStore.workflow.meta ?? {}), + ...this.workflowsStore.workflow.meta, instanceId: this.rootStore.instanceId, }, ...data, }; + delete workflowToCopy.meta.templateCredsSetupCompleted; + this.workflowHelpers.removeForeignCredentialsFromWorkflow( workflowToCopy, this.credentialsStore.allCredentials, @@ -2176,7 +2181,11 @@ export default defineComponent({ } } - return await this.importWorkflowData(workflowData!, 'paste', false); + if (!workflowData) { + return; + } + + return await this.importWorkflowData(workflowData, 'paste', false); } }, @@ -2203,7 +2212,7 @@ export default defineComponent({ // Imports the given workflow data into the current workflow async importWorkflowData( - workflowData: IWorkflowToShare, + workflowData: IWorkflowDataUpdate, source: string, importTags = true, ): Promise { @@ -2340,7 +2349,7 @@ export default defineComponent({ } }, - removeUnknownCredentials(workflow: IWorkflowToShare) { + removeUnknownCredentials(workflow: IWorkflowDataUpdate) { if (!workflow?.nodes) return; for (const node of workflow.nodes) { @@ -3789,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/editor-ui/src/views/SetupWorkflowFromTemplateView/__tests__/setupTemplate.store.test.ts b/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/__tests__/setupTemplate.store.test.ts index dd6f18d4d378d..1e907a1dd6761 100644 --- a/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/__tests__/setupTemplate.store.test.ts +++ b/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/__tests__/setupTemplate.store.test.ts @@ -93,6 +93,7 @@ describe('SetupWorkflowFromTemplateView store', () => { const templatesStore = useTemplatesStore(); const workflow = testData.newFullOneNodeTemplate({ + id: 'workflow', name: 'Test', type: 'n8n-nodes-base.httpRequest', typeVersion: 1, diff --git a/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/__tests__/useCredentialSetupState.test.ts b/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/__tests__/useCredentialSetupState.test.ts index 96c9162b9bf27..28299b475d257 100644 --- a/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/__tests__/useCredentialSetupState.test.ts +++ b/packages/editor-ui/src/views/SetupWorkflowFromTemplateView/__tests__/useCredentialSetupState.test.ts @@ -15,6 +15,7 @@ const objToMap = (obj: Record) => { describe('useCredentialSetupState', () => { const nodesByName = { Twitter: { + id: 'twitter', name: 'Twitter', type: 'n8n-nodes-base.twitter', position: [720, -220], @@ -58,12 +59,14 @@ describe('useCredentialSetupState', () => { it('returns credentials grouped when the credential names are the same', () => { const [node1, node2] = [ newWorkflowTemplateNode({ + id: 'twitter', type: 'n8n-nodes-base.twitter', credentials: { twitterOAuth1Api: 'credential', }, }) as IWorkflowTemplateNodeWithCredentials, newWorkflowTemplateNode({ + id: 'telegram', type: 'n8n-nodes-base.telegram', credentials: { telegramApi: 'credential', diff --git a/packages/editor-ui/src/views/TemplatesSearchView.vue b/packages/editor-ui/src/views/TemplatesSearchView.vue index 55cfdcf37c5e0..05a2359ea5fe9 100644 --- a/packages/editor-ui/src/views/TemplatesSearchView.vue +++ b/packages/editor-ui/src/views/TemplatesSearchView.vue @@ -122,6 +122,16 @@ export default defineComponent({ TemplateList, TemplatesView, }, + beforeRouteLeave(_to, _from, next) { + const contentArea = document.getElementById('content'); + if (contentArea) { + // When leaving this page, store current scroll position in route data + this.$route.meta?.setScrollPosition?.(contentArea.scrollTop); + } + + this.trackSearch(); + next(); + }, setup() { const { callDebounced } = useDebounce(); @@ -406,21 +416,6 @@ export default defineComponent({ }, 0); }, }, - beforeRouteLeave(to, from, next) { - const contentArea = document.getElementById('content'); - if (contentArea) { - // When leaving this page, store current scroll position in route data - if ( - this.$route.meta?.setScrollPosition && - typeof this.$route.meta.setScrollPosition === 'function' - ) { - this.$route.meta.setScrollPosition(contentArea.scrollTop); - } - } - - this.trackSearch(); - next(); - }, }); diff --git a/packages/editor-ui/src/views/VariablesView.vue b/packages/editor-ui/src/views/VariablesView.vue index d218afed9b721..734ab7280b1d7 100644 --- a/packages/editor-ui/src/views/VariablesView.vue +++ b/packages/editor-ui/src/views/VariablesView.vue @@ -122,7 +122,7 @@ const resourceToEnvironmentVariable = (data: IResource): EnvironmentVariable => return { id: data.id, key: data.name, - value: data.value, + value: 'value' in data ? data.value : '', }; }; diff --git a/packages/editor-ui/src/views/__tests__/SettingsUsersView.test.ts b/packages/editor-ui/src/views/__tests__/SettingsUsersView.test.ts index 39f07116ccd73..d8b9834b86b7d 100644 --- a/packages/editor-ui/src/views/__tests__/SettingsUsersView.test.ts +++ b/packages/editor-ui/src/views/__tests__/SettingsUsersView.test.ts @@ -12,7 +12,7 @@ import { createUser } from '@/__tests__/data/users'; import { createProjectListItem } from '@/__tests__/data/projects'; import { useRBACStore } from '@/stores/rbac.store'; import { DELETE_USER_MODAL_KEY } from '@/constants'; -import { expect } from 'vitest'; +import * as usersApi from '@/api/users'; const wrapperComponentWithModal = { components: { SettingsUsersView, ModalRoot, DeleteUserModal }, @@ -34,31 +34,34 @@ const loggedInUser = createUser(); const users = Array.from({ length: 3 }, createUser); const personalProjects = Array.from({ length: 3 }, createProjectListItem); +let pinia: ReturnType; let projectsStore: ReturnType; let usersStore: ReturnType; let rbacStore: ReturnType; describe('SettingsUsersView', () => { beforeEach(() => { - setActivePinia(createPinia()); + pinia = createPinia(); + setActivePinia(pinia); projectsStore = useProjectsStore(); usersStore = useUsersStore(); rbacStore = useRBACStore(); vi.spyOn(rbacStore, 'hasScope').mockReturnValue(true); - vi.spyOn(usersStore, 'fetchUsers').mockImplementation(async () => await Promise.resolve()); + vi.spyOn(usersApi, 'getUsers').mockResolvedValue(users); vi.spyOn(usersStore, 'allUsers', 'get').mockReturnValue(users); - vi.spyOn(usersStore, 'getUserById', 'get').mockReturnValue(() => loggedInUser); vi.spyOn(projectsStore, 'getAllProjects').mockImplementation( async () => await Promise.resolve(), ); vi.spyOn(projectsStore, 'personalProjects', 'get').mockReturnValue(personalProjects); + + usersStore.currentUserId = loggedInUser.id; }); it('should show confirmation modal before deleting user and delete with transfer', async () => { const deleteUserSpy = vi.spyOn(usersStore, 'deleteUser').mockImplementation(async () => {}); - const { getByTestId } = renderComponent(); + const { getByTestId } = renderComponent({ pinia }); const userListItem = getByTestId(`user-list-item-${users[0].email}`); expect(userListItem).toBeInTheDocument(); @@ -98,7 +101,7 @@ describe('SettingsUsersView', () => { it('should show confirmation modal before deleting user and delete without transfer', async () => { const deleteUserSpy = vi.spyOn(usersStore, 'deleteUser').mockImplementation(async () => {}); - const { getByTestId } = renderComponent(); + const { getByTestId } = renderComponent({ pinia }); const userListItem = getByTestId(`user-list-item-${users[0].email}`); expect(userListItem).toBeInTheDocument(); diff --git a/packages/editor-ui/tsconfig.json b/packages/editor-ui/tsconfig.json index 621157cd8a867..68bae33584d61 100644 --- a/packages/editor-ui/tsconfig.json +++ b/packages/editor-ui/tsconfig.json @@ -14,11 +14,7 @@ "types": [ "vitest/globals", "unplugin-icons/types/vue", - "./src/shims.d.ts", - "./src/shims-vue.d.ts", - "./src/v3-infinite-loading.d.ts", - "../workflow/src/types.d.ts", - "../design-system/src/shims-markdown-it.d.ts" + "../design-system/src/shims-modules.d.ts" ], "paths": { "@/*": ["./src/*"], diff --git a/packages/editor-ui/vite.config.mts b/packages/editor-ui/vite.config.mts index 6fc8bc12b9e53..7689d93af1bfa 100644 --- a/packages/editor-ui/vite.config.mts +++ b/packages/editor-ui/vite.config.mts @@ -77,8 +77,12 @@ const plugins = [ }), vue(), ]; -if (process.env.ENABLE_TYPE_CHECKING === 'true') { - plugins.push(checker({ vueTsc: true })); + +if (!process.env.VITEST) { + plugins.push({ + ...checker({ vueTsc: true }), + apply: 'build' + }); } const { SENTRY_AUTH_TOKEN: authToken, RELEASE: release } = process.env; diff --git a/packages/nodes-base/nodes/Wait/Wait.node.ts b/packages/nodes-base/nodes/Wait/Wait.node.ts index d796bbadc2bae..69907cdd62172 100644 --- a/packages/nodes-base/nodes/Wait/Wait.node.ts +++ b/packages/nodes-base/nodes/Wait/Wait.node.ts @@ -286,7 +286,7 @@ export class Wait extends Webhook { description: 'Waits for a webhook call before continuing', }, { - name: 'On Form Submited', + name: 'On Form Submitted', value: 'form', description: 'Waits for a form submission before continuing', }, diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 1e356d0d136c5..39add47db27c8 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -116,21 +116,21 @@ export interface IUser { lastName: string; } +export type ProjectSharingData = { + id: string; + name: string | null; + type: 'personal' | 'team' | 'public'; + createdAt: string; + updatedAt: string; +}; + export interface ICredentialsDecrypted { id: string; name: string; type: string; data?: ICredentialDataDecryptedObject; - homeProject?: { - id: string; - name: string | null; - type: 'personal' | 'team' | 'public'; - }; - sharedWithProjects?: Array<{ - id: string; - name: string | null; - type: 'personal' | 'team' | 'public'; - }>; + homeProject?: ProjectSharingData; + sharedWithProjects?: ProjectSharingData[]; } export interface ICredentialsEncrypted { @@ -340,7 +340,13 @@ export interface ICredentialData { } // The encrypted credentials which the nodes can access -export type CredentialInformation = string | number | boolean | IDataObject | IDataObject[]; +export type CredentialInformation = + | string + | string[] + | number + | boolean + | IDataObject + | IDataObject[]; // The encrypted credentials which the nodes can access export interface ICredentialDataDecryptedObject { @@ -1530,7 +1536,7 @@ export interface INodeIssueObjectProperty { export interface INodeIssueData { node: string; type: INodeIssueTypes; - value: boolean | string | string[] | INodeIssueObjectProperty; + value: null | boolean | string | string[] | INodeIssueObjectProperty; } export interface INodeIssues { @@ -1571,7 +1577,7 @@ export interface INodeTypeBaseDescription { icon?: Themed; iconColor?: NodeIconColor; iconUrl?: Themed; - badgeIconUrl?: string; + badgeIconUrl?: Themed; group: string[]; description: string; documentationUrl?: string; @@ -2371,7 +2377,7 @@ export interface ExecutionSummary { stoppedAt?: Date; workflowId: string; workflowName?: string; - status?: ExecutionStatus; + status: ExecutionStatus; lastNodeExecuted?: string; executionError?: ExecutionError; nodeExecutionStatus?: { @@ -2511,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/packages/workflow/src/MessageEventBus.ts b/packages/workflow/src/MessageEventBus.ts index 97ca4116b43e1..a6d44ced9f24d 100644 --- a/packages/workflow/src/MessageEventBus.ts +++ b/packages/workflow/src/MessageEventBus.ts @@ -21,6 +21,13 @@ export const enum MessageEventBusDestinationTypeNames { syslog = '$$MessageEventBusDestinationSyslog', } +export const messageEventBusDestinationTypeNames = [ + MessageEventBusDestinationTypeNames.abstract, + MessageEventBusDestinationTypeNames.webhook, + MessageEventBusDestinationTypeNames.sentry, + MessageEventBusDestinationTypeNames.syslog, +]; + // =============================== // Event Message Interfaces // =============================== diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2ddd7d6da463..bcb10455873d7 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,40 @@ 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 + '@sinonjs/fake-timers': + specifier: ^11.2.2 + version: 11.2.2 + 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: @@ -4658,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'} @@ -5733,9 +5755,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 +13175,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 +13356,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'} @@ -13766,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': {} @@ -17772,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 @@ -18969,7 +18992,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 +19596,6 @@ snapshots: '@types/uuid@8.3.4': {} - '@types/uuid@9.0.0': {} - '@types/uuid@9.0.7': {} '@types/validator@13.7.10': {} @@ -21459,7 +21480,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 +24563,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 +28355,6 @@ snapshots: uuid@8.3.2: {} - uuid@9.0.0: {} - uuid@9.0.1: {} v3-infinite-loading@1.2.2: {} @@ -28515,6 +28534,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