diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c81220d1b4..7b7a1e89d2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,7 +9,7 @@ "type=bind,source=${localEnv:HOME}/.n8n,target=/home/node/.n8n,consistency=cached" ], "forwardPorts": [8080, 5678], - "postCreateCommand": "corepack prepare --activate && pnpm install ", + "postCreateCommand": "corepack prepare --activate && pnpm install", "postAttachCommand": "pnpm build", "customizations": { "codespaces": { diff --git a/.github/workflows/benchmark-nightly.yml b/.github/workflows/benchmark-nightly.yml index a90cd12650..a144eb21b0 100644 --- a/.github/workflows/benchmark-nightly.yml +++ b/.github/workflows/benchmark-nightly.yml @@ -84,6 +84,7 @@ jobs: # We need to login again because the access token expires - name: Azure login + if: always() uses: azure/login@v2.1.1 with: client-id: ${{ env.ARM_CLIENT_ID }} diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index d2ebc05320..7c5682076a 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -4,19 +4,49 @@ on: workflow_dispatch: pull_request_review: types: [submitted] - branches: - - 'master' - paths: - - packages/design-system/** - - .github/workflows/chromatic.yml concurrency: group: chromatic-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: + get-metadata: + name: Get Metadata + runs-on: ubuntu-latest + steps: + - name: Check out current commit + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 2 + + - name: Determine changed files + uses: tomi/paths-filter-action@v3.0.2 + id: changed + if: github.event_name == 'pull_request_review' + with: + filters: | + design_system: + - packages/design-system/** + - .github/workflows/chromatic.yml + + outputs: + design_system_files_changed: ${{ steps.changed.outputs.design_system == 'true' }} + is_community_pr: ${{ contains(github.event.pull_request.labels.*.name, 'community') }} + is_pr_target_master: ${{ github.event.pull_request.base.ref == 'master' }} + is_dispatch: ${{ github.event_name == 'workflow_dispatch' }} + is_pr_approved: ${{ github.event.review.state == 'approved' }} + chromatic: - if: ${{ github.event.review.state == 'approved' && !contains(github.event.pull_request.labels.*.name, 'community') }} + needs: [get-metadata] + if: | + needs.get-metadata.outputs.is_dispatch == 'true' || + ( + needs.get-metadata.outputs.design_system_files_changed == 'true' && + needs.get-metadata.outputs.is_community_pr == 'false' && + needs.get-metadata.outputs.is_pr_target_master == 'true' && + needs.get-metadata.outputs.is_pr_approved == 'true' + ) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4.1.1 diff --git a/.github/workflows/ci-postgres-mysql.yml b/.github/workflows/ci-postgres-mysql.yml index 1d645ae774..23088633b5 100644 --- a/.github/workflows/ci-postgres-mysql.yml +++ b/.github/workflows/ci-postgres-mysql.yml @@ -10,8 +10,6 @@ on: - .github/workflows/ci-postgres-mysql.yml pull_request_review: types: [submitted] - branches: - - 'release/*' concurrency: group: db-${{ github.event.pull_request.number || github.ref }} @@ -21,6 +19,7 @@ jobs: build: name: Install & Build runs-on: ubuntu-latest + if: github.event_name != 'pull_request_review' || startsWith(github.event.pull_request.base.ref, 'release/') steps: - uses: actions/checkout@v4.1.1 - run: corepack enable diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index d9938983b1..48a34c5eab 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -30,6 +30,9 @@ jobs: - name: Build run: pnpm build + - name: Run formatcheck + run: pnpm format:check + - name: Run typecheck run: pnpm typecheck diff --git a/.github/workflows/e2e-tests-pr.yml b/.github/workflows/e2e-tests-pr.yml index 3d2f122638..047ce2d13a 100644 --- a/.github/workflows/e2e-tests-pr.yml +++ b/.github/workflows/e2e-tests-pr.yml @@ -3,19 +3,51 @@ name: PR E2E on: pull_request_review: types: [submitted] - branches: - - 'master' - - 'release/*' concurrency: group: e2e-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: + get-metadata: + name: Get Metadata + runs-on: ubuntu-latest + steps: + - name: Check out current commit + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 2 + + - name: Determine changed files + uses: tomi/paths-filter-action@v3.0.2 + id: changed + with: + filters: | + not_ignored: + - '!.devcontainer/**' + - '!.github/*' + - '!.github/scripts/*' + - '!.github/workflows/benchmark-*' + - '!.github/workflows/check-*' + - '!.vscode/**' + - '!docker/**' + - '!packages/@n8n/benchmark/**' + - '!**/*.md' + predicate-quantifier: 'every' + + outputs: + # The workflow should run when: + # - It has changes to files that are not ignored + # - It is not a community PR + # - It is targeting master or a release branch + should_run: ${{ steps.changed.outputs.not_ignored == 'true' && !contains(github.event.pull_request.labels.*.name, 'community') && (github.event.pull_request.base.ref == 'master' || startsWith(github.event.pull_request.base.ref, 'release/')) }} + run-e2e-tests: name: E2E [Electron/Node 18] uses: ./.github/workflows/e2e-reusable.yml - if: ${{ github.event.review.state == 'approved' && !contains(github.event.pull_request.labels.*.name, 'community') }} + needs: [get-metadata] + if: ${{ github.event.review.state == 'approved' && needs.get-metadata.outputs.should_run == 'true' }} with: pr_number: ${{ github.event.pull_request.number }} user: ${{ github.event.pull_request.user.login || 'PR User' }} @@ -25,11 +57,11 @@ jobs: post-e2e-tests: runs-on: ubuntu-latest name: E2E [Electron/Node 18] - Checks - needs: [run-e2e-tests] + needs: [get-metadata, run-e2e-tests] if: always() steps: - name: E2E success comment - if: ${{!contains(github.event.pull_request.labels.*.name, 'community') && needs.run-e2e-tests.outputs.tests_passed == 'true' }} + if: ${{ needs.get-metadata.outputs.should_run == 'true' && needs.run-e2e-tests.outputs.tests_passed == 'true' }} uses: peter-evans/create-or-update-comment@v4.0.0 with: issue-number: ${{ github.event.pull_request.number }} diff --git a/.prettierignore b/.prettierignore index 4ac8f0dafb..2f5967e399 100644 --- a/.prettierignore +++ b/.prettierignore @@ -7,3 +7,11 @@ packages/nodes-base/nodes/**/test packages/cli/templates/form-trigger.handlebars cypress/fixtures CHANGELOG.md +.github/pull_request_template.md +# Ignored for now +**/*.md +# Handled by biome +**/*.ts +**/*.js +**/*.json +**/*.jsonc diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 158d03fdc8..681de6c024 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,6 @@ { "recommendations": [ + "biomejs.biome", "streetsidesoftware.code-spell-checker", "dangmai.workspace-default-settings", "dbaeumer.vscode-eslint", diff --git a/.vscode/settings.default.json b/.vscode/settings.default.json index 87db2ca2ee..99c514f741 100644 --- a/.vscode/settings.default.json +++ b/.vscode/settings.default.json @@ -1,6 +1,22 @@ { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "editor.codeActionsOnSave": { + "quickfix.biome": "explicit", + "source.organizeImports.biome": "never" + }, "search.exclude": { "node_modules": true, "dist": true, diff --git a/biome.jsonc b/biome.jsonc new file mode 100644 index 0000000000..356c964cd9 --- /dev/null +++ b/biome.jsonc @@ -0,0 +1,48 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "vcs": { + "clientKind": "git", + "enabled": true, + "useIgnoreFile": true + }, + "files": { + "ignore": [ + "**/.turbo", + "**/coverage", + "**/dist", + "**/package.json", + "**/pnpm-lock.yaml", + "**/CHANGELOG.md" + ] + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "tab", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 100, + "attributePosition": "auto", + "ignore": [ + // Handled by prettier + "**/*.vue" + ] + }, + "organizeImports": { "enabled": false }, + "linter": { + "enabled": false + }, + "javascript": { + "formatter": { + "jsxQuoteStyle": "double", + "quoteProperties": "asNeeded", + "trailingCommas": "all", + "semicolons": "always", + "arrowParentheses": "always", + "bracketSpacing": true, + "bracketSameLine": false, + "quoteStyle": "single", + "attributePosition": "auto" + } + } +} diff --git a/cypress/biome.jsonc b/cypress/biome.jsonc new file mode 100644 index 0000000000..5a63363ac8 --- /dev/null +++ b/cypress/biome.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "../node_modules/@biomejs/biome/configuration_schema.json", + "extends": ["../biome.jsonc"], + "formatter": { + "ignore": ["fixtures/**"] + } +} diff --git a/cypress/composables/workflow.ts b/cypress/composables/workflow.ts index b3d6f20c28..8d37d5f2ad 100644 --- a/cypress/composables/workflow.ts +++ b/cypress/composables/workflow.ts @@ -1,5 +1,5 @@ -import { ROUTES } from '../constants'; import { getManualChatModal } from './modals/chat-modal'; +import { ROUTES } from '../constants'; /** * Types diff --git a/cypress/e2e/1-workflows.cy.ts b/cypress/e2e/1-workflows.cy.ts index d01f046d75..6835346012 100644 --- a/cypress/e2e/1-workflows.cy.ts +++ b/cypress/e2e/1-workflows.cy.ts @@ -1,5 +1,5 @@ -import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; +import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows'; import { getUniqueWorkflowName } from '../utils/workflowUtils'; const WorkflowsPage = new WorkflowsPageClass(); diff --git a/cypress/e2e/10-settings-log-streaming.cy.ts b/cypress/e2e/10-settings-log-streaming.cy.ts index 9acec76f42..1954543ca0 100644 --- a/cypress/e2e/10-settings-log-streaming.cy.ts +++ b/cypress/e2e/10-settings-log-streaming.cy.ts @@ -1,6 +1,6 @@ import { SettingsLogStreamingPage } from '../pages'; -import { getVisibleModalOverlay } from '../utils/modal'; import { getVisibleDropdown } from '../utils'; +import { getVisibleModalOverlay } from '../utils/modal'; const settingsLogStreamingPage = new SettingsLogStreamingPage(); diff --git a/cypress/e2e/10-undo-redo.cy.ts b/cypress/e2e/10-undo-redo.cy.ts index 6453443376..7e3b5ef8ad 100644 --- a/cypress/e2e/10-undo-redo.cy.ts +++ b/cypress/e2e/10-undo-redo.cy.ts @@ -4,9 +4,9 @@ import { 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'; +import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; // Suite-specific constants const CODE_NODE_NEW_NAME = 'Something else'; diff --git a/cypress/e2e/12-canvas-actions.cy.ts b/cypress/e2e/12-canvas-actions.cy.ts index 53dad1cc89..8a42521d84 100644 --- a/cypress/e2e/12-canvas-actions.cy.ts +++ b/cypress/e2e/12-canvas-actions.cy.ts @@ -1,5 +1,3 @@ -import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; -import { successToast } from '../pages/notifications'; import { MANUAL_TRIGGER_NODE_NAME, MANUAL_TRIGGER_NODE_DISPLAY_NAME, @@ -9,6 +7,8 @@ import { IF_NODE_NAME, HTTP_REQUEST_NODE_NAME, } from './../constants'; +import { successToast } from '../pages/notifications'; +import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; const WorkflowPage = new WorkflowPageClass(); describe('Canvas Actions', () => { diff --git a/cypress/e2e/12-canvas.cy.ts b/cypress/e2e/12-canvas.cy.ts index 325e509e79..4c7cccaafe 100644 --- a/cypress/e2e/12-canvas.cy.ts +++ b/cypress/e2e/12-canvas.cy.ts @@ -1,5 +1,3 @@ -import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; -import { NDV, WorkflowExecutionsTab } from '../pages'; import { MANUAL_TRIGGER_NODE_NAME, MANUAL_TRIGGER_NODE_DISPLAY_NAME, @@ -9,6 +7,8 @@ import { SWITCH_NODE_NAME, MERGE_NODE_NAME, } from './../constants'; +import { NDV, WorkflowExecutionsTab } from '../pages'; +import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; const WorkflowPage = new WorkflowPageClass(); const ExecutionsTab = new WorkflowExecutionsTab(); diff --git a/cypress/e2e/14-mapping.cy.ts b/cypress/e2e/14-mapping.cy.ts index 43103415e3..3bbbd0b293 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(); diff --git a/cypress/e2e/16-webhook-node.cy.ts b/cypress/e2e/16-webhook-node.cy.ts index 791a704174..9346004388 100644 --- a/cypress/e2e/16-webhook-node.cy.ts +++ b/cypress/e2e/16-webhook-node.cy.ts @@ -1,7 +1,8 @@ import { nanoid } from 'nanoid'; + +import { BACKEND_BASE_URL, EDIT_FIELDS_SET_NODE_NAME } from '../constants'; 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'; const workflowPage = new WorkflowPage(); diff --git a/cypress/e2e/17-sharing.cy.ts b/cypress/e2e/17-sharing.cy.ts index c90a884325..e2af15f101 100644 --- a/cypress/e2e/17-sharing.cy.ts +++ b/cypress/e2e/17-sharing.cy.ts @@ -1,3 +1,4 @@ +import * as projects from '../composables/projects'; import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN, NOTION_NODE_NAME } from '../constants'; import { CredentialsModal, @@ -8,7 +9,6 @@ import { WorkflowsPage, } from '../pages'; import { getVisibleDropdown, getVisiblePopper, getVisibleSelect } from '../utils'; -import * as projects from '../composables/projects'; /** * User U1 - Instance owner diff --git a/cypress/e2e/18-user-management.cy.ts b/cypress/e2e/18-user-management.cy.ts index b53b0fdf53..fe91a72935 100644 --- a/cypress/e2e/18-user-management.cy.ts +++ b/cypress/e2e/18-user-management.cy.ts @@ -1,8 +1,8 @@ import { INSTANCE_MEMBERS, INSTANCE_OWNER, INSTANCE_ADMIN } from '../constants'; import { MainSidebar, SettingsSidebar, SettingsUsersPage } from '../pages'; +import { errorToast, successToast } from '../pages/notifications'; import { PersonalSettingsPage } from '../pages/settings-personal'; import { getVisibleSelect } from '../utils'; -import { errorToast, successToast } from '../pages/notifications'; /** * User A - Instance owner diff --git a/cypress/e2e/19-execution.cy.ts b/cypress/e2e/19-execution.cy.ts index d2c463f1bb..d6b8d08fd5 100644 --- a/cypress/e2e/19-execution.cy.ts +++ b/cypress/e2e/19-execution.cy.ts @@ -1,5 +1,5 @@ -import { NDV, WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages'; import { SCHEDULE_TRIGGER_NODE_NAME, EDIT_FIELDS_SET_NODE_NAME } from '../constants'; +import { NDV, WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages'; import { clearNotifications, errorToast, successToast } from '../pages/notifications'; const workflowPage = new WorkflowPageClass(); diff --git a/cypress/e2e/2-credentials.cy.ts b/cypress/e2e/2-credentials.cy.ts index 9dbe6c6b5d..dbc613bd64 100644 --- a/cypress/e2e/2-credentials.cy.ts +++ b/cypress/e2e/2-credentials.cy.ts @@ -1,4 +1,5 @@ import { type ICredentialType } from 'n8n-workflow'; + import { AGENT_NODE_NAME, AI_TOOL_HTTP_NODE_NAME, diff --git a/cypress/e2e/20-workflow-executions.cy.ts b/cypress/e2e/20-workflow-executions.cy.ts index 5f2a260143..19256f3bf9 100644 --- a/cypress/e2e/20-workflow-executions.cy.ts +++ b/cypress/e2e/20-workflow-executions.cy.ts @@ -1,7 +1,8 @@ import type { RouteHandler } from 'cypress/types/net-stubbing'; + +import executionOutOfMemoryServerResponse from '../fixtures/responses/execution-out-of-memory-server-response.json'; import { WorkflowPage } from '../pages'; import { WorkflowExecutionsTab } from '../pages/workflow-executions-tab'; -import executionOutOfMemoryServerResponse from '../fixtures/responses/execution-out-of-memory-server-response.json'; import { getVisibleSelect } from '../utils'; const workflowPage = new WorkflowPage(); diff --git a/cypress/e2e/21-community-nodes.cy.ts b/cypress/e2e/21-community-nodes.cy.ts index bf88d3d24c..17f82ec573 100644 --- a/cypress/e2e/21-community-nodes.cy.ts +++ b/cypress/e2e/21-community-nodes.cy.ts @@ -1,11 +1,11 @@ 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'; -import CustomNodeWithN8nCredentialFixture from '../fixtures/Custom_node_n8n_credential.json'; -import CustomNodeWithCustomCredentialFixture from '../fixtures/Custom_node_custom_credential.json'; + import CustomCredential from '../fixtures/Custom_credential.json'; -import { getVisibleSelect } from '../utils'; +import CustomNodeFixture from '../fixtures/Custom_node.json'; +import CustomNodeWithCustomCredentialFixture from '../fixtures/Custom_node_custom_credential.json'; +import CustomNodeWithN8nCredentialFixture from '../fixtures/Custom_node_n8n_credential.json'; +import { CredentialsModal, WorkflowPage } from '../pages'; +import { NodeCreator } from '../pages/features/node-creator'; import { confirmCommunityNodeUninstall, confirmCommunityNodeUpdate, @@ -13,6 +13,7 @@ import { installFirstCommunityNode, visitCommunityNodesSettings, } from '../pages/settings-community-nodes'; +import { getVisibleSelect } from '../utils'; const credentialsModal = new CredentialsModal(); const nodeCreatorFeature = new NodeCreator(); diff --git a/cypress/e2e/233-AI-switch-to-logs-on-error.cy.ts b/cypress/e2e/233-AI-switch-to-logs-on-error.cy.ts index 4c733df90d..eca3af81fb 100644 --- a/cypress/e2e/233-AI-switch-to-logs-on-error.cy.ts +++ b/cypress/e2e/233-AI-switch-to-logs-on-error.cy.ts @@ -1,5 +1,18 @@ import type { ExecutionError } from 'n8n-workflow/src'; -import { NDV, WorkflowPage as WorkflowPageClass } from '../pages'; + +import { + closeManualChatModal, + getManualChatMessages, + getManualChatModalLogs, + getManualChatModalLogsEntries, + sendManualChatMessage, +} from '../composables/modals/chat-modal'; +import { setCredentialValues } from '../composables/modals/credential-modal'; +import { + clickCreateNewCredential, + clickExecuteNode, + clickGetBackToCanvas, +} from '../composables/ndv'; import { addLanguageModelNodeToParent, addMemoryNodeToParent, @@ -18,19 +31,7 @@ import { MANUAL_TRIGGER_NODE_DISPLAY_NAME, MANUAL_TRIGGER_NODE_NAME, } from '../constants'; -import { - clickCreateNewCredential, - clickExecuteNode, - clickGetBackToCanvas, -} from '../composables/ndv'; -import { setCredentialValues } from '../composables/modals/credential-modal'; -import { - closeManualChatModal, - getManualChatMessages, - getManualChatModalLogs, - getManualChatModalLogsEntries, - sendManualChatMessage, -} from '../composables/modals/chat-modal'; +import { NDV, WorkflowPage as WorkflowPageClass } from '../pages'; import { createMockNodeExecutionData, getVisibleSelect, runMockWorkflowExecution } from '../utils'; const ndv = new NDV(); diff --git a/cypress/e2e/24-ndv-paired-item.cy.ts b/cypress/e2e/24-ndv-paired-item.cy.ts index 8324144343..1261a0fcd1 100644 --- a/cypress/e2e/24-ndv-paired-item.cy.ts +++ b/cypress/e2e/24-ndv-paired-item.cy.ts @@ -227,7 +227,7 @@ describe('NDV', () => { workflowPage.actions.zoomToFit(); - /* prettier-ignore */ + // biome-ignore format: const PINNED_DATA = [ { "id": "abc", @@ -263,7 +263,6 @@ describe('NDV', () => { ] } ]; - /* prettier-ignore */ workflowPage.actions.openNode('Get thread details1'); ndv.actions.pastePinnedData(PINNED_DATA); ndv.actions.close(); diff --git a/cypress/e2e/27-cloud.cy.ts b/cypress/e2e/27-cloud.cy.ts index dd0d3b06ba..e9b814597d 100644 --- a/cypress/e2e/27-cloud.cy.ts +++ b/cypress/e2e/27-cloud.cy.ts @@ -1,3 +1,4 @@ +import planData from '../fixtures/Plan_data_opt_in_trial.json'; import { BannerStack, MainSidebar, @@ -5,7 +6,6 @@ import { visitPublicApiPage, getPublicApiUpgradeCTA, } from '../pages'; -import planData from '../fixtures/Plan_data_opt_in_trial.json'; const mainSidebar = new MainSidebar(); const bannerStack = new BannerStack(); diff --git a/cypress/e2e/27-two-factor-authentication.cy.ts b/cypress/e2e/27-two-factor-authentication.cy.ts index 21319dd79b..dc62a0c58c 100644 --- a/cypress/e2e/27-two-factor-authentication.cy.ts +++ b/cypress/e2e/27-two-factor-authentication.cy.ts @@ -1,9 +1,10 @@ import generateOTPToken from 'cypress-otp'; + +import { MainSidebar } from './../pages/sidebar/main-sidebar'; 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 { MainSidebar } from './../pages/sidebar/main-sidebar'; +import { PersonalSettingsPage } from '../pages/settings-personal'; const MFA_SECRET = 'KVKFKRCPNZQUYMLXOVYDSQKJKZDTSRLD'; diff --git a/cypress/e2e/29-templates.cy.ts b/cypress/e2e/29-templates.cy.ts index 5cc6657416..5b52889c94 100644 --- a/cypress/e2e/29-templates.cy.ts +++ b/cypress/e2e/29-templates.cy.ts @@ -1,9 +1,9 @@ +import OnboardingWorkflow from '../fixtures/Onboarding_workflow.json'; +import WorkflowTemplate from '../fixtures/Workflow_template_write_http_query.json'; +import { MainSidebar } from '../pages/sidebar/main-sidebar'; import { TemplatesPage } from '../pages/templates'; import { WorkflowPage } from '../pages/workflow'; import { WorkflowsPage } from '../pages/workflows'; -import { MainSidebar } from '../pages/sidebar/main-sidebar'; -import OnboardingWorkflow from '../fixtures/Onboarding_workflow.json'; -import WorkflowTemplate from '../fixtures/Workflow_template_write_http_query.json'; const templatesPage = new TemplatesPage(); const workflowPage = new WorkflowPage(); diff --git a/cypress/e2e/30-langchain.cy.ts b/cypress/e2e/30-langchain.cy.ts index c6d0f4ab4d..0deec76e9f 100644 --- a/cypress/e2e/30-langchain.cy.ts +++ b/cypress/e2e/30-langchain.cy.ts @@ -1,4 +1,34 @@ -import { createMockNodeExecutionData, runMockWorkflowExecution } from '../utils'; +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, + CHAT_TRIGGER_NODE_DISPLAY_NAME, +} from './../constants'; +import { + closeManualChatModal, + getManualChatDialog, + getManualChatMessages, + getManualChatModal, + getManualChatModalLogs, + getManualChatModalLogsEntries, + getManualChatModalLogsTree, + sendManualChatMessage, +} from '../composables/modals/chat-modal'; +import { setCredentialValues } from '../composables/modals/credential-modal'; +import { + clickCreateNewCredential, + clickExecuteNode, + clickGetBackToCanvas, + toggleParameterCheckboxInputByName, +} from '../composables/ndv'; import { addLanguageModelNodeToParent, addMemoryNodeToParent, @@ -14,37 +44,7 @@ import { openNode, getConnectionBySourceAndTarget, } from '../composables/workflow'; -import { - clickCreateNewCredential, - clickExecuteNode, - clickGetBackToCanvas, - toggleParameterCheckboxInputByName, -} from '../composables/ndv'; -import { setCredentialValues } from '../composables/modals/credential-modal'; -import { - closeManualChatModal, - getManualChatDialog, - getManualChatMessages, - getManualChatModal, - getManualChatModalLogs, - getManualChatModalLogsEntries, - 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, - CHAT_TRIGGER_NODE_DISPLAY_NAME, -} from './../constants'; +import { createMockNodeExecutionData, runMockWorkflowExecution } from '../utils'; describe('Langchain Integration', () => { beforeEach(() => { diff --git a/cypress/e2e/31-demo.cy.ts b/cypress/e2e/31-demo.cy.ts index eed3198c83..32307361fd 100644 --- a/cypress/e2e/31-demo.cy.ts +++ b/cypress/e2e/31-demo.cy.ts @@ -1,7 +1,7 @@ import workflow from '../fixtures/Manual_wait_set.json'; import { importWorkflow, visitDemoPage } from '../pages/demo'; -import { WorkflowPage } from '../pages/workflow'; import { errorToast } from '../pages/notifications'; +import { WorkflowPage } from '../pages/workflow'; const workflowPage = new WorkflowPage(); diff --git a/cypress/e2e/34-template-credentials-setup.cy.ts b/cypress/e2e/34-template-credentials-setup.cy.ts index c5d9f2643f..815f4b1ceb 100644 --- a/cypress/e2e/34-template-credentials-setup.cy.ts +++ b/cypress/e2e/34-template-credentials-setup.cy.ts @@ -1,3 +1,8 @@ +import * as setupCredsModal from '../composables/modals/workflow-credential-setup-modal'; +import * as formStep from '../composables/setup-template-form-step'; +import { getSetupWorkflowCredentialsButton } from '../composables/setup-workflow-credentials-button'; +import TestTemplate1 from '../fixtures/Test_Template_1.json'; +import TestTemplate2 from '../fixtures/Test_Template_2.json'; import { clickUseWorkflowButtonByTitle, visitTemplateCollectionPage, @@ -5,11 +10,6 @@ import { } from '../pages/template-collection'; import * as templateCredentialsSetupPage from '../pages/template-credential-setup'; import { WorkflowPage } from '../pages/workflow'; -import * as formStep from '../composables/setup-template-form-step'; -import { getSetupWorkflowCredentialsButton } from '../composables/setup-workflow-credentials-button'; -import * as setupCredsModal from '../composables/modals/workflow-credential-setup-modal'; -import TestTemplate1 from '../fixtures/Test_Template_1.json'; -import TestTemplate2 from '../fixtures/Test_Template_2.json'; const workflowPage = new WorkflowPage(); diff --git a/cypress/e2e/36-versions.cy.ts b/cypress/e2e/36-versions.cy.ts index 1d4fc51808..d749ae4537 100644 --- a/cypress/e2e/36-versions.cy.ts +++ b/cypress/e2e/36-versions.cy.ts @@ -1,10 +1,10 @@ -import { WorkflowsPage } from '../pages/workflows'; import { closeVersionUpdatesPanel, getVersionCard, getVersionUpdatesPanelOpenButton, openVersionUpdatesPanel, } from '../composables/versions'; +import { WorkflowsPage } from '../pages/workflows'; const workflowsPage = new WorkflowsPage(); diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts index e2bf63df7d..59ed6bcb84 100644 --- a/cypress/e2e/39-projects.cy.ts +++ b/cypress/e2e/39-projects.cy.ts @@ -1,3 +1,4 @@ +import * as projects from '../composables/projects'; import { INSTANCE_MEMBERS, MANUAL_TRIGGER_NODE_NAME, NOTION_NODE_NAME } from '../constants'; import { WorkflowsPage, @@ -8,7 +9,6 @@ import { NDV, MainSidebar, } from '../pages'; -import * as projects from '../composables/projects'; import { getVisibleDropdown, getVisibleModalOverlay, getVisibleSelect } from '../utils'; const workflowsPage = new WorkflowsPage(); diff --git a/cypress/e2e/4-node-creator.cy.ts b/cypress/e2e/4-node-creator.cy.ts index bb47ef4765..9dfe128322 100644 --- a/cypress/e2e/4-node-creator.cy.ts +++ b/cypress/e2e/4-node-creator.cy.ts @@ -1,8 +1,8 @@ -import { NodeCreator } from '../pages/features/node-creator'; -import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; -import { NDV } from '../pages/ndv'; -import { getVisibleSelect } from '../utils'; import { IF_NODE_NAME } from '../constants'; +import { NodeCreator } from '../pages/features/node-creator'; +import { NDV } from '../pages/ndv'; +import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; +import { getVisibleSelect } from '../utils'; const nodeCreatorFeature = new NodeCreator(); const WorkflowPage = new WorkflowPageClass(); diff --git a/cypress/e2e/44-routing.cy.ts b/cypress/e2e/44-routing.cy.ts index 67a092235b..1d3a8746a9 100644 --- a/cypress/e2e/44-routing.cy.ts +++ b/cypress/e2e/44-routing.cy.ts @@ -1,7 +1,7 @@ -import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows'; -import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; -import { EDIT_FIELDS_SET_NODE_NAME } from '../constants'; import { getSaveChangesModal } from '../composables/modals/save-changes-modal'; +import { EDIT_FIELDS_SET_NODE_NAME } from '../constants'; +import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; +import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows'; const WorkflowsPage = new WorkflowsPageClass(); const WorkflowPage = new WorkflowPageClass(); diff --git a/cypress/e2e/45-ai-assistant.cy.ts b/cypress/e2e/45-ai-assistant.cy.ts index 01d07cbd2b..7bf97eeaf9 100644 --- a/cypress/e2e/45-ai-assistant.cy.ts +++ b/cypress/e2e/45-ai-assistant.cy.ts @@ -374,3 +374,58 @@ describe('AI Assistant Credential Help', () => { aiAssistant.getters.credentialEditAssistantButton().should('be.disabled'); }); }); + +describe('General help', () => { + beforeEach(() => { + aiAssistant.actions.enableAssistant(); + wf.actions.visit(); + }); + + it('assistant returns code snippet', () => { + cy.intercept('POST', '/rest/ai-assistant/chat', { + statusCode: 200, + fixture: 'aiAssistant/code_snippet_response.json', + }).as('chatRequest'); + + aiAssistant.getters.askAssistantFloatingButton().should('be.visible'); + aiAssistant.getters.askAssistantFloatingButton().click(); + aiAssistant.getters.askAssistantChat().should('be.visible'); + aiAssistant.getters.placeholderMessage().should('be.visible'); + aiAssistant.getters.chatInput().should('be.visible'); + + aiAssistant.getters.chatInput().type('Show me an expression'); + aiAssistant.getters.sendMessageButton().click(); + + aiAssistant.getters.chatMessagesAll().should('have.length', 3); + aiAssistant.getters.chatMessagesUser().eq(0).should('contain.text', 'Show me an expression'); + + aiAssistant.getters + .chatMessagesAssistant() + .eq(0) + .should('contain.text', 'To use expressions in n8n, follow these steps:'); + + aiAssistant.getters + .chatMessagesAssistant() + .eq(0) + .should( + 'include.html', + `
[
+  {
+    "headers": {
+      "host": "n8n.instance.address",
+      ...
+    },
+    "params": {},
+    "query": {},
+    "body": {
+      "name": "Jim",
+      "age": 30,
+      "city": "New York"
+    }
+  }
+]
+
`, + ); + aiAssistant.getters.codeSnippet().should('have.text', '{{$json.body.city}}'); + }); +}); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index d38eb402b9..4608b5eefc 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -1,8 +1,8 @@ +import { setCredentialValues } from '../composables/modals/credential-modal'; +import { clickCreateNewCredential } from '../composables/ndv'; import { MANUAL_TRIGGER_NODE_DISPLAY_NAME, NOTION_NODE_NAME } from '../constants'; import { NDV, WorkflowPage } from '../pages'; import { NodeCreator } from '../pages/features/node-creator'; -import { clickCreateNewCredential } from '../composables/ndv'; -import { setCredentialValues } from '../composables/modals/credential-modal'; const workflowPage = new WorkflowPage(); const ndv = new NDV(); diff --git a/cypress/e2e/6-code-node.cy.ts b/cypress/e2e/6-code-node.cy.ts index 43486590c7..5b422b4589 100644 --- a/cypress/e2e/6-code-node.cy.ts +++ b/cypress/e2e/6-code-node.cy.ts @@ -1,7 +1,8 @@ import { nanoid } from 'nanoid'; -import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; + import { NDV } from '../pages/ndv'; import { successToast } from '../pages/notifications'; +import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; const WorkflowPage = new WorkflowPageClass(); const ndv = new NDV(); @@ -43,7 +44,9 @@ describe('Code node', () => { const getParameter = () => ndv.getters.parameterInput('jsCode').should('be.visible'); const getEditor = () => getParameter().find('.cm-content').should('exist'); - getEditor().type('{selectall}').paste(`$input.itemMatching() + getEditor() + .type('{selectall}') + .paste(`$input.itemMatching() $input.item $('When clicking ‘Test workflow’').item $input.first(1) @@ -68,7 +71,9 @@ return ndv.getters.parameterInput('mode').click(); ndv.actions.selectOptionInParameterDropdown('mode', 'Run Once for Each Item'); - getEditor().type('{selectall}').paste(`$input.itemMatching() + getEditor() + .type('{selectall}') + .paste(`$input.itemMatching() $input.all() $input.first() $input.item() diff --git a/cypress/e2e/7-workflow-actions.cy.ts b/cypress/e2e/7-workflow-actions.cy.ts index 7c7c3be554..8571b174d9 100644 --- a/cypress/e2e/7-workflow-actions.cy.ts +++ b/cypress/e2e/7-workflow-actions.cy.ts @@ -5,11 +5,11 @@ import { EDIT_FIELDS_SET_NODE_NAME, NOTION_NODE_NAME, } from '../constants'; +import { WorkflowExecutionsTab } from '../pages'; +import { errorToast, successToast } from '../pages/notifications'; 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'; diff --git a/cypress/fixtures/aiAssistant/code_snippet_response.json b/cypress/fixtures/aiAssistant/code_snippet_response.json new file mode 100644 index 0000000000..b05f212de1 --- /dev/null +++ b/cypress/fixtures/aiAssistant/code_snippet_response.json @@ -0,0 +1,28 @@ +{ + "sessionId": "f1d19ed5-0d55-4bad-b49a-f0c56bd6f76f-705b5dbf-12d4-4805-87a3-1e5b3c716d29-W1JgVNrpfitpSNF9rAjB4", + "messages": [ + { + "role": "assistant", + "type": "message", + "text": "To use expressions in n8n, follow these steps:\n\n1. Hover over the parameter where you want to use an expression.\n2. Select **Expressions** in the **Fixed/Expression** toggle.\n3. Write your expression in the parameter, or select **Open expression editor** to open the expressions editor. You can browse the available data in the **Variable selector**. All expressions have the format `{{ your expression here }}`.\n\n### Example: Get data from webhook body\n\nIf your webhook data looks like this:\n\n```json\n[\n {\n \"headers\": {\n \"host\": \"n8n.instance.address\",\n ...\n },\n \"params\": {},\n \"query\": {},\n \"body\": {\n \"name\": \"Jim\",\n \"age\": 30,\n \"city\": \"New York\"\n }\n }\n]\n```\n\nYou can use the following expression to get the value of `city`:\n\n```js\n{{$json.body.city}}\n```\n\nThis expression accesses the incoming JSON-formatted data using n8n's custom `$json` variable and finds the value of `city` (in this example, \"New York\").", + "codeSnippet": "{{$json.body.city}}" + }, + { + "role": "assistant", + "type": "message", + "text": "Did this answer solve your question?", + "quickReplies": [ + { + "text": "Yes, thanks", + "type": "all-good", + "isFeedback": true + }, + { + "text": "No, I am still stuck", + "type": "still-stuck", + "isFeedback": true + } + ] + } + ] +} diff --git a/cypress/package.json b/cypress/package.json index 5084e0b10c..e64be91c2b 100644 --- a/cypress/package.json +++ b/cypress/package.json @@ -7,13 +7,15 @@ "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", + "format": "biome format --write .", + "format:check": "biome ci .", "lint": "eslint . --quiet", "lintfix": "eslint . --fix", "develop": "cd ..; pnpm dev", "start": "cd ..; pnpm start" }, "devDependencies": { + "@n8n/api-types": "workspace:*", "@types/lodash": "catalog:", "eslint-plugin-cypress": "^3.3.0", "n8n-workflow": "workspace:*" diff --git a/cypress/pages/features/ai-assistant.ts b/cypress/pages/features/ai-assistant.ts index 8434074737..ea77724dcf 100644 --- a/cypress/pages/features/ai-assistant.ts +++ b/cypress/pages/features/ai-assistant.ts @@ -37,6 +37,7 @@ export class AIAssistant extends BasePage { cy.getByTestId('node-error-view-ask-assistant-button').find('button').first(), credentialEditAssistantButton: () => cy.getByTestId('credentail-edit-ask-assistant-button').find('button').first(), + codeSnippet: () => cy.getByTestId('assistant-code-snippet'), }; actions = { diff --git a/cypress/pages/mfa-login.ts b/cypress/pages/mfa-login.ts index ae4d916ba9..66fc197e3f 100644 --- a/cypress/pages/mfa-login.ts +++ b/cypress/pages/mfa-login.ts @@ -1,7 +1,7 @@ -import { N8N_AUTH_COOKIE } from '../constants'; import { BasePage } from './base'; import { SigninPage } from './signin'; import { WorkflowsPage } from './workflows'; +import { N8N_AUTH_COOKIE } from '../constants'; export class MfaLoginPage extends BasePage { url = '/mfa'; diff --git a/cypress/pages/modals/credentials-modal.ts b/cypress/pages/modals/credentials-modal.ts index f14f6be0b7..cd3ded63f8 100644 --- a/cypress/pages/modals/credentials-modal.ts +++ b/cypress/pages/modals/credentials-modal.ts @@ -1,5 +1,5 @@ -import { BasePage } from '../base'; import { getVisibleSelect } from '../../utils'; +import { BasePage } from '../base'; export class CredentialsModal extends BasePage { getters = { diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index 8bd7ccf95f..b775deec6d 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -1,5 +1,5 @@ -import { getVisiblePopper, getVisibleSelect } from '../utils'; import { BasePage } from './base'; +import { getVisiblePopper, getVisibleSelect } from '../utils'; export class NDV extends BasePage { getters = { diff --git a/cypress/pages/settings-log-streaming.ts b/cypress/pages/settings-log-streaming.ts index cc1ea1250d..9063b8dc41 100644 --- a/cypress/pages/settings-log-streaming.ts +++ b/cypress/pages/settings-log-streaming.ts @@ -1,5 +1,5 @@ -import { getVisibleSelect } from '../utils'; import { BasePage } from './base'; +import { getVisibleSelect } from '../utils'; export class SettingsLogStreamingPage extends BasePage { url = '/settings/log-streaming'; diff --git a/cypress/pages/settings-personal.ts b/cypress/pages/settings-personal.ts index 9872fbc668..4574f95691 100644 --- a/cypress/pages/settings-personal.ts +++ b/cypress/pages/settings-personal.ts @@ -1,7 +1,8 @@ import generateOTPToken from 'cypress-otp'; + +import { BasePage } from './base'; import { ChangePasswordModal } from './modals/change-password-modal'; import { MfaSetupModal } from './modals/mfa-setup-modal'; -import { BasePage } from './base'; const changePasswordModal = new ChangePasswordModal(); const mfaSetupModal = new MfaSetupModal(); diff --git a/cypress/pages/settings-users.ts b/cypress/pages/settings-users.ts index d188896225..1eaebc911a 100644 --- a/cypress/pages/settings-users.ts +++ b/cypress/pages/settings-users.ts @@ -1,8 +1,8 @@ -import { SettingsSidebar } from './sidebar/settings-sidebar'; +import { BasePage } from './base'; import { MainSidebar } from './sidebar/main-sidebar'; +import { SettingsSidebar } from './sidebar/settings-sidebar'; import { WorkflowPage } from './workflow'; import { WorkflowsPage } from './workflows'; -import { BasePage } from './base'; const workflowPage = new WorkflowPage(); const workflowsPage = new WorkflowsPage(); diff --git a/cypress/pages/signin.ts b/cypress/pages/signin.ts index 22d0fd163a..a97fe4888e 100644 --- a/cypress/pages/signin.ts +++ b/cypress/pages/signin.ts @@ -1,6 +1,6 @@ -import { N8N_AUTH_COOKIE } from '../constants'; import { BasePage } from './base'; import { WorkflowsPage } from './workflows'; +import { N8N_AUTH_COOKIE } from '../constants'; export class SigninPage extends BasePage { url = '/signin'; diff --git a/cypress/pages/template-credential-setup.ts b/cypress/pages/template-credential-setup.ts index 3fa4d20671..06c1baab8e 100644 --- a/cypress/pages/template-credential-setup.ts +++ b/cypress/pages/template-credential-setup.ts @@ -1,6 +1,6 @@ -import * as formStep from '../composables/setup-template-form-step'; -import { overrideFeatureFlag } from '../composables/featureFlags'; import { CredentialsModal, MessageBox } from './modals'; +import { overrideFeatureFlag } from '../composables/featureFlags'; +import * as formStep from '../composables/setup-template-form-step'; const credentialsModal = new CredentialsModal(); const messageBox = new MessageBox(); diff --git a/cypress/pages/variables.ts b/cypress/pages/variables.ts index c74624686e..6ac9a939b2 100644 --- a/cypress/pages/variables.ts +++ b/cypress/pages/variables.ts @@ -1,4 +1,5 @@ import { BasePage } from './base'; + import Chainable = Cypress.Chainable; export class VariablesPage extends BasePage { diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index 0c2a269607..89186ee34e 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -1,8 +1,8 @@ +import { BasePage } from './base'; +import { NodeCreator } from './features/node-creator'; import { META_KEY } from '../constants'; import { getVisibleSelect } from '../utils'; import { getUniqueWorkflowName } from '../utils/workflowUtils'; -import { BasePage } from './base'; -import { NodeCreator } from './features/node-creator'; const nodeCreator = new NodeCreator(); export class WorkflowPage extends BasePage { diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 7bef3727dc..35f100fded 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,7 +1,7 @@ import 'cypress-real-events'; +import type { FrontendSettings } from '@n8n/api-types'; import FakeTimers from '@sinonjs/fake-timers'; -import type { IN8nUISettings } from 'n8n-workflow'; -import { WorkflowPage } from '../pages'; + import { BACKEND_BASE_URL, INSTANCE_ADMIN, @@ -9,6 +9,7 @@ import { INSTANCE_OWNER, N8N_AUTH_COOKIE, } from '../constants'; +import { WorkflowPage } from '../pages'; import { getUniqueWorkflowName } from '../utils/workflowUtils'; Cypress.Commands.add('setAppDate', (targetDate: number | Date) => { @@ -86,8 +87,8 @@ Cypress.Commands.add('signout', () => { cy.getCookie(N8N_AUTH_COOKIE).should('not.exist'); }); -export let settings: Partial; -Cypress.Commands.add('overrideSettings', (value: Partial) => { +export let settings: Partial; +Cypress.Commands.add('overrideSettings', (value: Partial) => { settings = value; }); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 4d5d7a7f9a..0fe782499d 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -1,5 +1,6 @@ import cloneDeep from 'lodash/cloneDeep'; import merge from 'lodash/merge'; + import { settings } from './commands'; before(() => { diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 7c1897b11f..a5f1caf5b2 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -1,7 +1,7 @@ // Load type definitions that come with Cypress module /// -import type { IN8nUISettings } from 'n8n-workflow'; +import type { FrontendSettings } from '@n8n/api-types'; Cypress.Keyboard.defaults({ keystrokeDelay: 0, @@ -45,7 +45,7 @@ declare global { */ signinAsMember(index?: number): void; signout(): void; - overrideSettings(value: Partial): void; + overrideSettings(value: Partial): void; enableFeature(feature: string): void; disableFeature(feature: string): void; enableQueueMode(): void; diff --git a/cypress/utils/executions.ts b/cypress/utils/executions.ts index e42e2152d6..eb0dbfc251 100644 --- a/cypress/utils/executions.ts +++ b/cypress/utils/executions.ts @@ -1,5 +1,6 @@ -import { nanoid } from 'nanoid'; import type { IDataObject, IPinData, ITaskData, ITaskDataConnections } from 'n8n-workflow'; +import { nanoid } from 'nanoid'; + import { clickExecuteWorkflowButton } from '../composables/workflow'; export function createMockNodeExecutionData( diff --git a/docker/images/n8n-custom/Dockerfile b/docker/images/n8n-custom/Dockerfile index 17f0d1c517..ba271017d1 100644 --- a/docker/images/n8n-custom/Dockerfile +++ b/docker/images/n8n-custom/Dockerfile @@ -6,7 +6,7 @@ FROM --platform=linux/amd64 n8nio/base:${NODE_VERSION} AS builder # Build the application from source WORKDIR /src COPY . /src -RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store --mount=type=cache,id=pnpm-metadata,target=/root/.cache/pnpm/metadata pnpm install --frozen-lockfile +RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store --mount=type=cache,id=pnpm-metadata,target=/root/.cache/pnpm/metadata DOCKER_BUILD=true pnpm install --frozen-lockfile RUN pnpm build # Delete all dev dependencies @@ -18,7 +18,7 @@ RUN find . -type f -name "*.ts" -o -name "*.js.map" -o -name "*.vue" -o -name "t # Deploy the `n8n` package into /compiled RUN mkdir /compiled -RUN NODE_ENV=production pnpm --filter=n8n --prod --no-optional deploy /compiled +RUN NODE_ENV=production DOCKER_BUILD=true pnpm --filter=n8n --prod --no-optional deploy /compiled # 2. Start with a new clean image with just the code that is needed to run n8n FROM n8nio/base:${NODE_VERSION} diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000000..bb58014db9 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,10 @@ +pre-commit: + commands: + biome_check: + glob: 'packages/**/*.{js,ts,json}' + run: ./node_modules/.bin/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files} + stage_fixed: true + prettier_check: + glob: 'packages/**/*.{vue,yml,md}' + run: ./node_modules/.bin/prettier --write --ignore-unknown --no-error-on-unmatched-pattern {staged_files} + stage_fixed: true diff --git a/n8n.code-workspace b/n8n.code-workspace index 9d32d7aa04..8f4183e8f0 100644 --- a/n8n.code-workspace +++ b/n8n.code-workspace @@ -1,7 +1,7 @@ { "folders": [ { - "path": ".", - }, - ], + "path": "." + } + ] } diff --git a/package.json b/package.json index d854a0c577..98d9b2cc20 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ }, "packageManager": "pnpm@9.6.0", "scripts": { + "prepare": "node scripts/prepare.mjs", "preinstall": "node scripts/block-npm-install.js", "build": "turbo run build", "build:backend": "turbo run build:backend", @@ -19,6 +20,7 @@ "clean": "turbo run clean --parallel", "reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs", "format": "turbo run format && node scripts/format.mjs", + "format:check": "turbo run format:check", "lint": "turbo run lint", "lintfix": "turbo run lintfix", "lint:backend": "turbo run lint:backend", @@ -38,6 +40,7 @@ "worker": "./packages/cli/bin/n8n worker" }, "devDependencies": { + "@biomejs/biome": "^1.9.0", "@n8n_io/eslint-config": "workspace:*", "@types/jest": "^29.5.3", "@types/supertest": "^6.0.2", @@ -46,6 +49,7 @@ "jest-expect-message": "^1.1.3", "jest-mock": "^29.6.2", "jest-mock-extended": "^3.0.4", + "lefthook": "^1.7.15", "nock": "^13.3.2", "nodemon": "^3.0.1", "p-limit": "^3.1.0", @@ -73,7 +77,7 @@ "semver": "^7.5.4", "tslib": "^2.6.2", "tsconfig-paths": "^4.2.0", - "typescript": "^5.5.2", + "typescript": "^5.6.2", "ws": ">=8.17.1" }, "patchedDependencies": { diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index 2e6d17a2a8..07466a7a71 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -6,7 +6,8 @@ "dev": "pnpm watch", "typecheck": "tsc --noEmit", "build": "tsc -p tsconfig.build.json", - "format": "prettier --write . --ignore-path ../../../.prettierignore", + "format": "biome format --write .", + "format:check": "biome ci .", "lint": "eslint .", "lintfix": "eslint . --fix", "watch": "tsc -p tsconfig.build.json --watch", diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts new file mode 100644 index 0000000000..8815f14f05 --- /dev/null +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -0,0 +1,172 @@ +import type { ExpressionEvaluatorType, LogLevel, WorkflowSettings } from 'n8n-workflow'; + +export interface IVersionNotificationSettings { + enabled: boolean; + endpoint: string; + infoUrl: string; +} + +export interface ITelemetryClientConfig { + url: string; + key: string; +} + +export interface ITelemetrySettings { + enabled: boolean; + config?: ITelemetryClientConfig; +} + +export type AuthenticationMethod = 'email' | 'ldap' | 'saml'; + +export interface IUserManagementSettings { + quota: number; + showSetupOnFirstLoad?: boolean; + smtpSetup: boolean; + authenticationMethod: AuthenticationMethod; +} + +export interface FrontendSettings { + isDocker?: boolean; + databaseType: 'sqlite' | 'mariadb' | 'mysqldb' | 'postgresdb'; + endpointForm: string; + endpointFormTest: string; + endpointFormWaiting: string; + endpointWebhook: string; + endpointWebhookTest: string; + saveDataErrorExecution: WorkflowSettings.SaveDataExecution; + saveDataSuccessExecution: WorkflowSettings.SaveDataExecution; + saveManualExecutions: boolean; + saveExecutionProgress: boolean; + executionTimeout: number; + maxExecutionTimeout: number; + workflowCallerPolicyDefaultOption: WorkflowSettings.CallerPolicy; + oauthCallbackUrls: { + oauth1: string; + oauth2: string; + }; + timezone: string; + urlBaseWebhook: string; + urlBaseEditor: string; + versionCli: string; + nodeJsVersion: string; + concurrency: number; + authCookie: { + secure: boolean; + }; + binaryDataMode: 'default' | 'filesystem' | 's3'; + releaseChannel: 'stable' | 'beta' | 'nightly' | 'dev'; + n8nMetadata?: { + userId?: string; + [key: string]: string | number | undefined; + }; + versionNotifications: IVersionNotificationSettings; + instanceId: string; + telemetry: ITelemetrySettings; + posthog: { + enabled: boolean; + apiHost: string; + apiKey: string; + autocapture: boolean; + disableSessionRecording: boolean; + debug: boolean; + }; + personalizationSurveyEnabled: boolean; + defaultLocale: string; + userManagement: IUserManagementSettings; + sso: { + saml: { + loginLabel: string; + loginEnabled: boolean; + }; + ldap: { + loginLabel: string; + loginEnabled: boolean; + }; + }; + publicApi: { + enabled: boolean; + latestVersion: number; + path: string; + swaggerUi: { + enabled: boolean; + }; + }; + workflowTagsDisabled: boolean; + logLevel: LogLevel; + hiringBannerEnabled: boolean; + previewMode: boolean; + templates: { + enabled: boolean; + host: string; + }; + missingPackages?: boolean; + executionMode: 'regular' | 'queue'; + pushBackend: 'sse' | 'websocket'; + communityNodesEnabled: boolean; + aiAssistant: { + enabled: boolean; + }; + deployment: { + type: string; + }; + isNpmAvailable: boolean; + allowedModules: { + builtIn?: string[]; + external?: string[]; + }; + enterprise: { + sharing: boolean; + ldap: boolean; + saml: boolean; + logStreaming: boolean; + advancedExecutionFilters: boolean; + variables: boolean; + sourceControl: boolean; + auditLogs: boolean; + externalSecrets: boolean; + showNonProdBanner: boolean; + debugInEditor: boolean; + binaryDataS3: boolean; + workflowHistory: boolean; + workerView: boolean; + advancedPermissions: boolean; + projects: { + team: { + limit: number; + }; + }; + }; + hideUsagePage: boolean; + license: { + planName?: string; + consumerId: string; + environment: 'development' | 'production' | 'staging'; + }; + variables: { + limit: number; + }; + expressions: { + evaluator: ExpressionEvaluatorType; + }; + mfa: { + enabled: boolean; + }; + banners: { + dismissed: string[]; + }; + ai: { + enabled: boolean; + }; + workflowHistory: { + pruneTime: number; + licensePruneTime: number; + }; + pruning: { + isEnabled: boolean; + maxAge: number; + maxCount: number; + }; + security: { + blockFileAccessToN8nFiles: boolean; + }; +} diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 5d59a1b768..89b6a3541c 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -1,6 +1,7 @@ +export type * from './datetime'; export type * from './push'; export type * from './scaling'; -export type * from './datetime'; +export type * from './frontend-settings'; export type * from './user'; export type { Collaborator } from './push/collaboration'; diff --git a/packages/@n8n/api-types/src/push/index.ts b/packages/@n8n/api-types/src/push/index.ts index 3eefb0851c..b97a179141 100644 --- a/packages/@n8n/api-types/src/push/index.ts +++ b/packages/@n8n/api-types/src/push/index.ts @@ -1,10 +1,10 @@ -import type { ExecutionPushMessage } from './execution'; -import type { WorkflowPushMessage } from './workflow'; -import type { HotReloadPushMessage } from './hot-reload'; -import type { WorkerPushMessage } from './worker'; -import type { WebhookPushMessage } from './webhook'; import type { CollaborationPushMessage } from './collaboration'; import type { DebugPushMessage } from './debug'; +import type { ExecutionPushMessage } from './execution'; +import type { HotReloadPushMessage } from './hot-reload'; +import type { WebhookPushMessage } from './webhook'; +import type { WorkerPushMessage } from './worker'; +import type { WorkflowPushMessage } from './workflow'; export type PushMessage = | ExecutionPushMessage diff --git a/packages/@n8n/benchmark/Dockerfile b/packages/@n8n/benchmark/Dockerfile index 5fa1aeae93..9525a9a4c2 100644 --- a/packages/@n8n/benchmark/Dockerfile +++ b/packages/@n8n/benchmark/Dockerfile @@ -33,6 +33,7 @@ COPY --chown=node:node ./packages/@n8n/benchmark/package.json /app/packages/@n8n COPY --chown=node:node ./patches /app/patches COPY --chown=node:node ./scripts /app/scripts +ENV DOCKER_BUILD=true RUN pnpm install --frozen-lockfile # TS config files diff --git a/packages/@n8n/benchmark/biome.jsonc b/packages/@n8n/benchmark/biome.jsonc new file mode 100644 index 0000000000..3db16975c6 --- /dev/null +++ b/packages/@n8n/benchmark/biome.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "../node_modules/@biomejs/biome/configuration_schema.json", + "extends": ["../../../biome.jsonc"], + "files": { + "ignore": ["scripts/mock-api/**"] + } +} diff --git a/packages/@n8n/benchmark/package.json b/packages/@n8n/benchmark/package.json index 67292b07c9..6294391c4c 100644 --- a/packages/@n8n/benchmark/package.json +++ b/packages/@n8n/benchmark/package.json @@ -5,6 +5,8 @@ "main": "dist/index", "scripts": { "build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json", + "format": "biome format --write .", + "format:check": "biome ci .", "lint": "eslint .", "lintfix": "eslint . --fix", "start": "./bin/n8n-benchmark", diff --git a/packages/@n8n/benchmark/scripts/bootstrap.sh b/packages/@n8n/benchmark/scripts/bootstrap.sh index 87f2c8d6ac..02858094c8 100644 --- a/packages/@n8n/benchmark/scripts/bootstrap.sh +++ b/packages/@n8n/benchmark/scripts/bootstrap.sh @@ -37,6 +37,17 @@ else sudo chown -R "$CURRENT_USER":"$CURRENT_USER" /n8n fi +### Remove unneeded dependencies +# TTY +sudo systemctl disable getty@tty1.service +sudo systemctl disable serial-getty@ttyS0.service +# Snap +sudo systemctl disable snapd.service +# Unattended upgrades +sudo systemctl disable unattended-upgrades.service +# Cron +sudo systemctl disable cron.service + # Include nodejs v20 repository curl -fsSL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh sudo -E bash nodesource_setup.sh diff --git a/packages/@n8n/benchmark/scripts/run-in-cloud.mjs b/packages/@n8n/benchmark/scripts/run-in-cloud.mjs index 35e90bdee5..c61c0901d4 100755 --- a/packages/@n8n/benchmark/scripts/run-in-cloud.mjs +++ b/packages/@n8n/benchmark/scripts/run-in-cloud.mjs @@ -78,6 +78,12 @@ async function runBenchmarksOnVm(config, benchmarkEnv) { const bootstrapScriptPath = path.join(scriptsDir, 'bootstrap.sh'); await sshClient.ssh(`chmod a+x ${bootstrapScriptPath} && ${bootstrapScriptPath}`); + // Benchmarking the VM + const vmBenchmarkScriptPath = path.join(scriptsDir, 'vm-benchmark.sh'); + await sshClient.ssh(`chmod a+x ${vmBenchmarkScriptPath} && ${vmBenchmarkScriptPath}`, { + verbose: true, + }); + // Give some time for the VM to be ready await sleep(1000); diff --git a/packages/@n8n/benchmark/scripts/vm-benchmark.sh b/packages/@n8n/benchmark/scripts/vm-benchmark.sh new file mode 100644 index 0000000000..13b7eb2b1a --- /dev/null +++ b/packages/@n8n/benchmark/scripts/vm-benchmark.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# Install fio +DEBIAN_FRONTEND=noninteractive sudo apt-get -y install fio > /dev/null + +# Run the disk benchmark +fio --name=rand_rw --ioengine=libaio --rw=randrw --rwmixread=70 --bs=4k --numjobs=4 --size=1G --runtime=30 --directory=/n8n --group_reporting + +# Remove files +sudo rm /n8n/rand_rw.* + +# Uninstall fio +DEBIAN_FRONTEND=noninteractive sudo apt-get -y remove fio > /dev/null diff --git a/packages/@n8n/benchmark/src/commands/list.ts b/packages/@n8n/benchmark/src/commands/list.ts index 7bcc4de2ce..34b8273209 100644 --- a/packages/@n8n/benchmark/src/commands/list.ts +++ b/packages/@n8n/benchmark/src/commands/list.ts @@ -1,6 +1,7 @@ import { Command } from '@oclif/core'; -import { ScenarioLoader } from '@/scenario/scenario-loader'; + import { testScenariosPath } from '@/config/common-flags'; +import { ScenarioLoader } from '@/scenario/scenario-loader'; export default class ListCommand extends Command { static description = 'List all available scenarios'; diff --git a/packages/@n8n/benchmark/src/commands/run.ts b/packages/@n8n/benchmark/src/commands/run.ts index eb895c842e..164eef0f41 100644 --- a/packages/@n8n/benchmark/src/commands/run.ts +++ b/packages/@n8n/benchmark/src/commands/run.ts @@ -1,11 +1,12 @@ import { Command, Flags } from '@oclif/core'; -import { ScenarioLoader } from '@/scenario/scenario-loader'; -import { ScenarioRunner } from '@/test-execution/scenario-runner'; + +import { testScenariosPath } from '@/config/common-flags'; import { N8nApiClient } from '@/n8n-api-client/n8n-api-client'; import { ScenarioDataFileLoader } from '@/scenario/scenario-data-loader'; +import { ScenarioLoader } from '@/scenario/scenario-loader'; import type { K6Tag } from '@/test-execution/k6-executor'; import { K6Executor } from '@/test-execution/k6-executor'; -import { testScenariosPath } from '@/config/common-flags'; +import { ScenarioRunner } from '@/test-execution/scenario-runner'; export default class RunCommand extends Command { static description = 'Run all (default) or specified test scenarios'; diff --git a/packages/@n8n/benchmark/src/n8n-api-client/authenticated-n8n-api-client.ts b/packages/@n8n/benchmark/src/n8n-api-client/authenticated-n8n-api-client.ts index 0272181799..2555ea8061 100644 --- a/packages/@n8n/benchmark/src/n8n-api-client/authenticated-n8n-api-client.ts +++ b/packages/@n8n/benchmark/src/n8n-api-client/authenticated-n8n-api-client.ts @@ -1,4 +1,5 @@ import type { AxiosRequestConfig } from 'axios'; + import { N8nApiClient } from './n8n-api-client'; export class AuthenticatedN8nApiClient extends N8nApiClient { diff --git a/packages/@n8n/benchmark/src/n8n-api-client/workflows-api-client.ts b/packages/@n8n/benchmark/src/n8n-api-client/workflows-api-client.ts index 76eea4284c..92bcfad89f 100644 --- a/packages/@n8n/benchmark/src/n8n-api-client/workflows-api-client.ts +++ b/packages/@n8n/benchmark/src/n8n-api-client/workflows-api-client.ts @@ -1,6 +1,7 @@ -import type { AuthenticatedN8nApiClient } from './authenticated-n8n-api-client'; import type { Workflow } from '@/n8n-api-client/n8n-api-client.types'; +import type { AuthenticatedN8nApiClient } from './authenticated-n8n-api-client'; + export class WorkflowApiClient { constructor(private readonly apiClient: AuthenticatedN8nApiClient) {} diff --git a/packages/@n8n/benchmark/src/scenario/scenario-data-loader.ts b/packages/@n8n/benchmark/src/scenario/scenario-data-loader.ts index b601d19902..12fcc58ee3 100644 --- a/packages/@n8n/benchmark/src/scenario/scenario-data-loader.ts +++ b/packages/@n8n/benchmark/src/scenario/scenario-data-loader.ts @@ -1,7 +1,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import type { Scenario } from '@/types/scenario'; + import type { Workflow } from '@/n8n-api-client/n8n-api-client.types'; +import type { Scenario } from '@/types/scenario'; /** * Loads scenario data files from FS diff --git a/packages/@n8n/benchmark/src/scenario/scenario-loader.ts b/packages/@n8n/benchmark/src/scenario/scenario-loader.ts index 13cc52daf0..4f315c1bf7 100644 --- a/packages/@n8n/benchmark/src/scenario/scenario-loader.ts +++ b/packages/@n8n/benchmark/src/scenario/scenario-loader.ts @@ -1,6 +1,7 @@ +import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'path'; -import { createHash } from 'node:crypto'; + import type { Scenario, ScenarioManifest } from '@/types/scenario'; export class ScenarioLoader { diff --git a/packages/@n8n/benchmark/src/test-execution/k6-executor.ts b/packages/@n8n/benchmark/src/test-execution/k6-executor.ts index f99ffd80d9..4fcf11d45b 100644 --- a/packages/@n8n/benchmark/src/test-execution/k6-executor.ts +++ b/packages/@n8n/benchmark/src/test-execution/k6-executor.ts @@ -1,9 +1,10 @@ import fs from 'fs'; -import path from 'path'; import assert from 'node:assert/strict'; +import path from 'path'; import { $, which, tmpfile } from 'zx'; -import type { Scenario } from '@/types/scenario'; + import { buildTestReport, type K6Tag } from '@/test-execution/test-report'; +import type { Scenario } from '@/types/scenario'; export type { K6Tag }; export type K6ExecutorOpts = { diff --git a/packages/@n8n/benchmark/src/test-execution/scenario-runner.ts b/packages/@n8n/benchmark/src/test-execution/scenario-runner.ts index 135ebc6007..84d1d8b096 100644 --- a/packages/@n8n/benchmark/src/test-execution/scenario-runner.ts +++ b/packages/@n8n/benchmark/src/test-execution/scenario-runner.ts @@ -1,9 +1,10 @@ -import type { K6Executor } from './k6-executor'; -import type { Scenario } from '@/types/scenario'; +import { AuthenticatedN8nApiClient } from '@/n8n-api-client/authenticated-n8n-api-client'; import type { N8nApiClient } from '@/n8n-api-client/n8n-api-client'; import type { ScenarioDataFileLoader } from '@/scenario/scenario-data-loader'; import { ScenarioDataImporter } from '@/test-execution/scenario-data-importer'; -import { AuthenticatedN8nApiClient } from '@/n8n-api-client/authenticated-n8n-api-client'; +import type { Scenario } from '@/types/scenario'; + +import type { K6Executor } from './k6-executor'; /** * Runs scenarios diff --git a/packages/@n8n/benchmark/src/test-execution/test-report.ts b/packages/@n8n/benchmark/src/test-execution/test-report.ts index d3177aeb79..8e858c5e66 100644 --- a/packages/@n8n/benchmark/src/test-execution/test-report.ts +++ b/packages/@n8n/benchmark/src/test-execution/test-report.ts @@ -1,4 +1,5 @@ import { nanoid } from 'nanoid'; + import type { Scenario } from '@/types/scenario'; export type K6Tag = { diff --git a/packages/@n8n/chat/package.json b/packages/@n8n/chat/package.json index 8a3b5b343c..1d319a743c 100644 --- a/packages/@n8n/chat/package.json +++ b/packages/@n8n/chat/package.json @@ -12,7 +12,8 @@ "typecheck": "vue-tsc --noEmit", "lint": "eslint . --ext .js,.ts,.vue --quiet", "lintfix": "eslint . --ext .js,.ts,.vue --fix", - "format": "prettier --write src/", + "format": "biome format --write src .storybook && prettier --write src/ --ignore-path ../../.prettierignore", + "format:check": "biome ci src .storybook && prettier --check src/ --ignore-path ../../.prettierignore", "storybook": "storybook dev -p 6006 --no-open", "build:storybook": "storybook build" }, diff --git a/packages/@n8n/chat/src/App.vue b/packages/@n8n/chat/src/App.vue index ec90bef425..0614b7cf73 100644 --- a/packages/@n8n/chat/src/App.vue +++ b/packages/@n8n/chat/src/App.vue @@ -1,8 +1,9 @@ diff --git a/packages/@n8n/chat/src/components/Input.vue b/packages/@n8n/chat/src/components/Input.vue index af4b3343b7..3e823917e0 100644 --- a/packages/@n8n/chat/src/components/Input.vue +++ b/packages/@n8n/chat/src/components/Input.vue @@ -1,12 +1,14 @@ ', + }, + global: { + directives: { + n8nHtml, + }, + }, + }); + expect(html()).toBe( + '', + ); + }); + + it('should not touch safe html', async () => { + const { html } = render(TestComponent, { + props: { + html: 'textsafeOk', + }, + global: { + directives: { + n8nHtml, + }, + }, + }); + expect(html()).toBe( + '
textsafeOk
', + ); + }); +}); diff --git a/packages/design-system/src/directives/n8n-html.ts b/packages/design-system/src/directives/n8n-html.ts new file mode 100644 index 0000000000..875905d1d9 --- /dev/null +++ b/packages/design-system/src/directives/n8n-html.ts @@ -0,0 +1,37 @@ +import sanitize from 'sanitize-html'; +import type { DirectiveBinding, ObjectDirective } from 'vue'; + +/** + * Custom directive `n8nHtml` to replace v-html from Vue to sanitize content. + * + * Usage: + * In your Vue template, use the directive `v-n8n-html` passing the unsafe HTML. + * + * Example: + *

link'"> + * + * Compiles to:

link

+ * + * Hint: Do not use it on components + * https://vuejs.org/guide/reusability/custom-directives#usage-on-components + */ + +const configuredSanitize = (html: string) => + sanitize(html, { + allowedTags: sanitize.defaults.allowedTags.concat(['img', 'input']), + allowedAttributes: { + ...sanitize.defaults.allowedAttributes, + input: ['type', 'id', 'checked'], + code: ['class'], + a: sanitize.defaults.allowedAttributes.a.concat(['data-*']), + }, + }); + +export const n8nHtml: ObjectDirective = { + beforeMount(el: HTMLElement, binding: DirectiveBinding) { + el.innerHTML = configuredSanitize(binding.value); + }, + beforeUpdate(el: HTMLElement, binding: DirectiveBinding) { + el.innerHTML = configuredSanitize(binding.value); + }, +}; diff --git a/packages/design-system/src/directives/n8n-truncate.test.ts b/packages/design-system/src/directives/n8n-truncate.test.ts new file mode 100644 index 0000000000..89cd771283 --- /dev/null +++ b/packages/design-system/src/directives/n8n-truncate.test.ts @@ -0,0 +1,77 @@ +import { render } from '@testing-library/vue'; + +import { n8nTruncate } from './n8n-truncate'; + +describe('Directive n8n-truncate', () => { + it('should truncate text to 30 chars by default', async () => { + const { html } = render( + { + props: { + text: { + type: String, + }, + }, + template: '
{{text}}
', + }, + { + props: { + text: 'This is a very long text that should be truncated', + }, + global: { + directives: { + n8nTruncate, + }, + }, + }, + ); + expect(html()).toBe('
This is a very long text that...
'); + }); + + it('should truncate text to 30 chars in case of wrong argument', async () => { + const { html } = render( + { + props: { + text: { + type: String, + }, + }, + template: '
{{text}}
', + }, + { + props: { + text: 'This is a very long text that should be truncated', + }, + global: { + directives: { + n8nTruncate, + }, + }, + }, + ); + expect(html()).toBe('
This is a very long text that...
'); + }); + + it('should truncate text to given length', async () => { + const { html } = render( + { + props: { + text: { + type: String, + }, + }, + template: '
{{text}}
', + }, + { + props: { + text: 'This is a very long text that should be truncated', + }, + global: { + directives: { + n8nTruncate, + }, + }, + }, + ); + expect(html()).toBe('
This is a very long text...
'); + }); +}); diff --git a/packages/design-system/src/directives/n8n-truncate.ts b/packages/design-system/src/directives/n8n-truncate.ts new file mode 100644 index 0000000000..27e678f4d5 --- /dev/null +++ b/packages/design-system/src/directives/n8n-truncate.ts @@ -0,0 +1,27 @@ +import type { DirectiveBinding, ObjectDirective } from 'vue'; + +import { truncate } from '../utils/string'; + +/** + * Custom directive `n8nTruncate` to truncate text content of an HTML element. + * + * Usage: + * In your Vue template, use the directive `v-n8n-truncate` with an argument to specify the length to truncate to. + * + * Example: + *

Some long text that will be truncated

+ * + * This will truncate the text content of the paragraph to 10 characters. + * + * Hint: Do not use it on components + * https://vuejs.org/guide/reusability/custom-directives#usage-on-components + */ + +export const n8nTruncate: ObjectDirective = { + mounted(el: HTMLElement, binding: DirectiveBinding) { + el.textContent = truncate(el.textContent ?? '', Number(binding.arg) || undefined); + }, + updated(el: HTMLElement, binding: DirectiveBinding) { + el.textContent = truncate(el.textContent ?? '', Number(binding.arg) || undefined); + }, +}; diff --git a/packages/design-system/src/locale/index.ts b/packages/design-system/src/locale/index.ts index 325b1d0dcd..48652a5a65 100644 --- a/packages/design-system/src/locale/index.ts +++ b/packages/design-system/src/locale/index.ts @@ -1,7 +1,8 @@ -import defaultLang from '../locale/lang/en'; -import createFormatTemplate from './format'; import type { N8nLocale, N8nLocaleTranslateFn } from 'n8n-design-system/types'; +import createFormatTemplate from './format'; +import defaultLang from '../locale/lang/en'; + // import { ElementLocale } from 'element-plus'; // import ElementLang from 'element-plus/lib/locale/lang/en'; // diff --git a/packages/design-system/src/main.ts b/packages/design-system/src/main.ts index 54934d6f1c..dddb89f888 100644 --- a/packages/design-system/src/main.ts +++ b/packages/design-system/src/main.ts @@ -5,4 +5,5 @@ export * from './components'; export * from './plugin'; export * from './types'; export * from './utils'; +export * from './directives'; export { locale }; diff --git a/packages/design-system/src/plugin.ts b/packages/design-system/src/plugin.ts index 493fde38ec..446dc579dc 100644 --- a/packages/design-system/src/plugin.ts +++ b/packages/design-system/src/plugin.ts @@ -1,5 +1,7 @@ import type { Component, Plugin } from 'vue'; + import * as components from './components'; +import * as directives from './directives'; export interface N8nPluginOptions {} @@ -8,5 +10,9 @@ export const N8nPlugin: Plugin = { for (const [name, component] of Object.entries(components)) { app.component(name, component as unknown as Component); } + + for (const [name, directive] of Object.entries(directives)) { + app.directive(name, directive); + } }, }; diff --git a/packages/design-system/src/styleguide/ColorCircles.vue b/packages/design-system/src/styleguide/ColorCircles.vue index 007d731bd8..55f4c1f9bc 100644 --- a/packages/design-system/src/styleguide/ColorCircles.vue +++ b/packages/design-system/src/styleguide/ColorCircles.vue @@ -1,5 +1,6 @@ + + diff --git a/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.spec.ts b/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.spec.ts index 1e251abc87..93ca4f13fd 100644 --- a/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.spec.ts +++ b/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.spec.ts @@ -1,10 +1,10 @@ -import { fireEvent } from '@testing-library/vue'; import CanvasEdge, { type CanvasEdgeProps } from './CanvasEdge.vue'; import { createComponentRenderer } from '@/__tests__/render'; import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; import { Position } from '@vue-flow/core'; import { NodeConnectionType } from 'n8n-workflow'; +import userEvent from '@testing-library/user-event'; const DEFAULT_PROPS = { sourceX: 0, @@ -31,18 +31,21 @@ beforeEach(() => { describe('CanvasEdge', () => { it('should emit delete event when toolbar delete is clicked', async () => { const { emitted, getByTestId } = renderComponent(); + await userEvent.hover(getByTestId('edge-label-wrapper')); const deleteButton = getByTestId('delete-connection-button'); - await fireEvent.click(deleteButton); + await userEvent.click(deleteButton); expect(emitted()).toHaveProperty('delete'); }); it('should emit add event when toolbar add is clicked', async () => { const { emitted, getByTestId } = renderComponent(); + await userEvent.hover(getByTestId('edge-label-wrapper')); + const addButton = getByTestId('add-connection-button'); - await fireEvent.click(addButton); + await userEvent.click(addButton); expect(emitted()).toHaveProperty('add'); }); @@ -54,6 +57,8 @@ describe('CanvasEdge', () => { }, }); + await userEvent.hover(getByTestId('edge-label-wrapper')); + expect(() => getByTestId('add-connection-button')).toThrow(); expect(() => getByTestId('delete-connection-button')).toThrow(); }); diff --git a/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.vue b/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.vue index 78af35b1cd..cb384489a9 100644 --- a/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.vue +++ b/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.vue @@ -3,9 +3,9 @@ import type { CanvasConnectionData } from '@/types'; import { isValidNodeConnectionType } from '@/utils/typeGuards'; import type { Connection, EdgeProps } from '@vue-flow/core'; -import { BaseEdge, EdgeLabelRenderer } from '@vue-flow/core'; +import { useVueFlow, BaseEdge, EdgeLabelRenderer } from '@vue-flow/core'; import { NodeConnectionType } from 'n8n-workflow'; -import { computed, useCssModule } from 'vue'; +import { computed, useCssModule, ref } from 'vue'; import CanvasEdgeToolbar from './CanvasEdgeToolbar.vue'; import { getCustomPath } from './utils/edgePath'; @@ -21,6 +21,19 @@ export type CanvasEdgeProps = EdgeProps & { const props = defineProps(); +const { onEdgeMouseEnter, onEdgeMouseLeave } = useVueFlow(); + +const isHovered = ref(false); + +onEdgeMouseEnter(({ edge }) => { + if (edge.id !== props.id) return; + isHovered.value = true; +}); +onEdgeMouseLeave(({ edge }) => { + if (edge.id !== props.id) return; + isHovered.value = false; +}); + const $style = useCssModule(); const connectionType = computed(() => @@ -29,7 +42,7 @@ const connectionType = computed(() => : NodeConnectionType.Main, ); -const isFocused = computed(() => props.selected || props.hovered); +const renderToolbar = computed(() => (props.selected || isHovered.value) && !props.readOnly); const status = computed(() => props.data.status); const statusColor = computed(() => { @@ -49,22 +62,10 @@ const statusColor = computed(() => { const edgeStyle = computed(() => ({ ...props.style, strokeWidth: 2, - stroke: statusColor.value, + stroke: isHovered.value ? 'var(--color-primary)' : statusColor.value, })); -const edgeLabel = computed(() => { - if (isFocused.value && !props.readOnly) { - return ''; - } - - return props.label; -}); - -const edgeLabelStyle = computed(() => ({ - fill: statusColor.value, - transform: 'translateY(calc(var(--spacing-xs) * -1))', - fontSize: 'var(--font-size-xs)', -})); +const edgeLabelStyle = computed(() => ({ color: statusColor.value })); const edgeToolbarStyle = computed(() => { const [, labelX, labelY] = path.value; @@ -73,13 +74,6 @@ const edgeToolbarStyle = computed(() => { }; }); -const edgeToolbarClasses = computed(() => ({ - [$style.edgeToolbar]: true, - [$style.edgeToolbarVisible]: isFocused.value, - nodrag: true, - nopan: true, -})); - const path = computed(() => getCustomPath(props)); const connection = computed(() => ({ @@ -105,39 +99,47 @@ function onDelete() { :style="edgeStyle" :path="path[0]" :marker-end="markerEnd" - :label="edgeLabel" - :label-x="path[1]" - :label-y="path[2]" - :label-style="edgeLabelStyle" - :label-show-bg="false" + :interaction-width="40" /> - - +
+ :class="$style.edgeLabelWrapper" + @mouseenter="isHovered = true" + @mouseleave="isHovered = false" + > + +
{{ label }}
+
diff --git a/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdgeToolbar.vue b/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdgeToolbar.vue index fab32bf008..cf487042d5 100644 --- a/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdgeToolbar.vue +++ b/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdgeToolbar.vue @@ -70,4 +70,8 @@ function onDelete() { --button-background-color: var(--color-background-base); --button-hover-background-color: var(--color-background-light); } + +.canvas-edge-toolbar-button { + border-width: 2px; +} 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 85d8dedad7..bc443c1732 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 @@ -96,7 +96,7 @@ function openContextMenu(event: MouseEvent) {
diff --git a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionAnnotationPanel.vue b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionAnnotationPanel.vue index 1ae4ef0e5c..437cfd8e12 100644 --- a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionAnnotationPanel.vue +++ b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionAnnotationPanel.vue @@ -179,7 +179,7 @@ const onTagsEditEsc = () => {
- +
diff --git a/packages/editor-ui/src/composables/__tests__/useClipboard.test.ts b/packages/editor-ui/src/composables/__tests__/useClipboard.test.ts index 6c4b977a0b..cf2637fb5a 100644 --- a/packages/editor-ui/src/composables/__tests__/useClipboard.test.ts +++ b/packages/editor-ui/src/composables/__tests__/useClipboard.test.ts @@ -1,4 +1,4 @@ -import { render } from '@testing-library/vue'; +import { render, within } from '@testing-library/vue'; import userEvent from '@testing-library/user-event'; import { defineComponent, h, ref } from 'vue'; import { useClipboard } from '@/composables/useClipboard'; @@ -8,9 +8,13 @@ const testValue = 'This is a test'; const TestComponent = defineComponent({ setup() { const pasted = ref(''); + const htmlContent = ref(); const clipboard = useClipboard({ onPaste(data) { pasted.value = data; + if (htmlContent.value) { + htmlContent.value.innerHTML = data; + } }, }); @@ -23,6 +27,7 @@ const TestComponent = defineComponent({ }, }), h('div', { 'data-test-id': 'paste' }, pasted.value), + h('div', { 'data-test-id': 'xss-attack', ref: htmlContent }), ]); }, }); @@ -68,4 +73,12 @@ describe('useClipboard()', () => { expect(pasteElement.textContent).toEqual(testValue); }); }); + + it('sanitizes HTML', async () => { + const unsafeHtml = 'https://www.ex.com/sfefdfdfdf/xdfef.json'; + const { getByTestId } = render(TestComponent); + + await userEvent.paste(unsafeHtml); + expect(within(getByTestId('xss-attack')).queryByRole('img')).not.toBeInTheDocument(); + }); }); diff --git a/packages/editor-ui/src/composables/__tests__/useWorkflowHelpers.spec.ts b/packages/editor-ui/src/composables/__tests__/useWorkflowHelpers.spec.ts index f50261a1aa..ac7d5addab 100644 --- a/packages/editor-ui/src/composables/__tests__/useWorkflowHelpers.spec.ts +++ b/packages/editor-ui/src/composables/__tests__/useWorkflowHelpers.spec.ts @@ -7,6 +7,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsEEStore } from '@/stores/workflows.ee.store'; import { useTagsStore } from '@/stores/tags.store'; import { createTestWorkflow } from '@/__tests__/mocks'; +import type { AssignmentCollectionValue } from 'n8n-workflow'; const getDuplicateTestWorkflow = (): IWorkflowDataUpdate => ({ name: 'Duplicate webhook test', @@ -70,6 +71,163 @@ describe('useWorkflowHelpers', () => { vi.clearAllMocks(); }); + describe('getNodeParametersWithResolvedExpressions', () => { + it('should correctly detect and resolve expressions in a regular node ', () => { + const nodeParameters = { + curlImport: '', + method: 'GET', + url: '={{ $json.name }}', + authentication: 'none', + provideSslCertificates: false, + sendQuery: false, + sendHeaders: false, + sendBody: false, + options: {}, + infoMessage: '', + }; + const workflowHelpers = useWorkflowHelpers({ router }); + const resolvedParameters = + workflowHelpers.getNodeParametersWithResolvedExpressions(nodeParameters); + expect(resolvedParameters.url).toHaveProperty('resolvedExpressionValue'); + }); + + it('should correctly detect and resolve expressions in a node with assignments (set node) ', () => { + const nodeParameters = { + mode: 'manual', + duplicateItem: false, + assignments: { + assignments: [ + { + id: '25d2d012-089b-424d-bfc6-642982a0711f', + name: 'date', + value: + "={{ DateTime.fromFormat('2023-12-12', 'dd/MM/yyyy').toISODate().plus({7, 'days' }) }}", + type: 'number', + }, + ], + }, + includeOtherFields: false, + options: {}, + }; + const workflowHelpers = useWorkflowHelpers({ router }); + const resolvedParameters = + workflowHelpers.getNodeParametersWithResolvedExpressions(nodeParameters); + expect(resolvedParameters).toHaveProperty('assignments'); + const assignments = resolvedParameters.assignments as AssignmentCollectionValue; + expect(assignments).toHaveProperty('assignments'); + expect(assignments.assignments[0].value).toHaveProperty('resolvedExpressionValue'); + }); + + it('should correctly detect and resolve expressions in a node with filter component', () => { + const nodeParameters = { + mode: 'rules', + rules: { + values: [ + { + conditions: { + options: { + caseSensitive: true, + leftValue: '', + typeValidation: 'strict', + version: 2, + }, + conditions: [ + { + leftValue: "={{ $('Edit Fields 1').item.json.name }}", + rightValue: 12, + operator: { + type: 'number', + operation: 'equals', + }, + }, + ], + combinator: 'and', + }, + renameOutput: false, + }, + ], + }, + looseTypeValidation: false, + options: {}, + }; + const workflowHelpers = useWorkflowHelpers({ router }); + const resolvedParameters = workflowHelpers.getNodeParametersWithResolvedExpressions( + nodeParameters, + ) as typeof nodeParameters; + expect(resolvedParameters).toHaveProperty('rules'); + expect(resolvedParameters.rules).toHaveProperty('values'); + expect(resolvedParameters.rules.values[0].conditions.conditions[0].leftValue).toHaveProperty( + 'resolvedExpressionValue', + ); + }); + it('should correctly detect and resolve expressions in a node with resource locator component', () => { + const nodeParameters = { + authentication: 'oAuth2', + resource: 'sheet', + operation: 'read', + documentId: { + __rl: true, + value: "={{ $('Edit Fields').item.json.document }}", + mode: 'id', + }, + sheetName: { + __rl: true, + value: "={{ $('Edit Fields').item.json.sheet }}", + mode: 'id', + }, + filtersUI: {}, + combineFilters: 'AND', + options: {}, + }; + const workflowHelpers = useWorkflowHelpers({ router }); + const resolvedParameters = workflowHelpers.getNodeParametersWithResolvedExpressions( + nodeParameters, + ) as typeof nodeParameters; + expect(resolvedParameters.documentId.value).toHaveProperty('resolvedExpressionValue'); + expect(resolvedParameters.sheetName.value).toHaveProperty('resolvedExpressionValue'); + }); + it('should correctly detect and resolve expressions in a node with resource mapper component', () => { + const nodeParameters = { + authentication: 'oAuth2', + resource: 'sheet', + operation: 'read', + documentId: { + __rl: true, + value: '1BAjxEhlUu5tXDCMQcjqjguIZDFuct3FYkdo7flxl3yc', + mode: 'list', + cachedResultName: 'Mapping sheet', + cachedResultUrl: + 'https://docs.google.com/spreadsheets/d/1BAjxEhlUu5tXDCMQcjqjguIZDFuct3FYkdo7flxl3yc/edit?usp=drivesdk', + }, + sheetName: { + __rl: true, + value: 'gid=0', + mode: 'list', + cachedResultName: 'Users', + cachedResultUrl: + 'https://docs.google.com/spreadsheets/d/1BAjxEhlUu5tXDCMQcjqjguIZDFuct3FYkdo7flxl3yc/edit#gid=0', + }, + filtersUI: { + values: [ + { + lookupColumn: 'First name', + lookupValue: "={{ $('Edit Fields 1').item.json.userName }}", + }, + ], + }, + combineFilters: 'AND', + options: {}, + }; + const workflowHelpers = useWorkflowHelpers({ router }); + const resolvedParameters = workflowHelpers.getNodeParametersWithResolvedExpressions( + nodeParameters, + ) as typeof nodeParameters; + expect(resolvedParameters.filtersUI.values[0].lookupValue).toHaveProperty( + 'resolvedExpressionValue', + ); + }); + }); + describe('saveAsNewWorkflow', () => { it('should respect `resetWebhookUrls: false` when duplicating workflows', async () => { const workflow = getDuplicateTestWorkflow(); diff --git a/packages/editor-ui/src/composables/useClipboard.ts b/packages/editor-ui/src/composables/useClipboard.ts index 1cd4c9ef70..9401b95c45 100644 --- a/packages/editor-ui/src/composables/useClipboard.ts +++ b/packages/editor-ui/src/composables/useClipboard.ts @@ -1,6 +1,7 @@ import { onBeforeUnmount, onMounted, ref } from 'vue'; import { useClipboard as useClipboardCore } from '@vueuse/core'; import { useDebounce } from '@/composables/useDebounce'; +import { sanitizeIfString } from '@/utils/htmlUtils'; type ClipboardEventFn = (data: string, event?: ClipboardEvent) => void; @@ -42,7 +43,7 @@ export function useClipboard( const clipboardData = event.clipboardData; if (clipboardData !== null) { - const clipboardValue = clipboardData.getData('text/plain'); + const clipboardValue = sanitizeIfString(clipboardData.getData('text/plain')); onPasteCallback.value(clipboardValue, event); } } diff --git a/packages/editor-ui/src/composables/useExpressionEditor.ts b/packages/editor-ui/src/composables/useExpressionEditor.ts index 416080a8ad..9b52589a7a 100644 --- a/packages/editor-ui/src/composables/useExpressionEditor.ts +++ b/packages/editor-ui/src/composables/useExpressionEditor.ts @@ -405,7 +405,8 @@ export const useExpressionEditor = ({ if (pos === 'lastExpression') { const END_OF_EXPRESSION = ' }}'; const endOfLastExpression = readEditorValue().lastIndexOf(END_OF_EXPRESSION); - pos = endOfLastExpression !== -1 ? endOfLastExpression : editor.value?.state.doc.length ?? 0; + pos = + endOfLastExpression !== -1 ? endOfLastExpression : (editor.value?.state.doc.length ?? 0); } else if (pos === 'end') { pos = editor.value?.state.doc.length ?? 0; } @@ -414,7 +415,7 @@ export const useExpressionEditor = ({ function select(anchor: number, head: number | 'end' = 'end'): void { editor.value?.dispatch({ - selection: { anchor, head: head === 'end' ? editor.value?.state.doc.length ?? 0 : head }, + selection: { anchor, head: head === 'end' ? (editor.value?.state.doc.length ?? 0) : head }, }); } diff --git a/packages/editor-ui/src/composables/useMessage.ts b/packages/editor-ui/src/composables/useMessage.ts index e24dc4579b..22bb50fc69 100644 --- a/packages/editor-ui/src/composables/useMessage.ts +++ b/packages/editor-ui/src/composables/useMessage.ts @@ -1,5 +1,6 @@ import type { ElMessageBoxOptions, Action, MessageBoxInputData } from 'element-plus'; import { ElMessageBox as MessageBox } from 'element-plus'; +import { sanitizeIfString } from '@/utils/htmlUtils'; export type MessageBoxConfirmResult = 'confirm' | 'cancel'; @@ -28,11 +29,13 @@ export function useMessage() { }; if (typeof configOrTitle === 'string') { - return await MessageBox.alert(message, configOrTitle, resolvedConfig).catch( + return await MessageBox.alert(sanitizeIfString(message), configOrTitle, resolvedConfig).catch( handleCancelOrClose, ); } - return await MessageBox.alert(message, resolvedConfig).catch(handleCancelOrClose); + return await MessageBox.alert(sanitizeIfString(message), resolvedConfig).catch( + handleCancelOrClose, + ); } async function confirm( @@ -50,12 +53,16 @@ export function useMessage() { }; if (typeof configOrTitle === 'string') { - return await MessageBox.confirm(message, configOrTitle, resolvedConfig).catch( - handleCancelOrClose, - ); + return await MessageBox.confirm( + sanitizeIfString(message), + sanitizeIfString(configOrTitle), + resolvedConfig, + ).catch(handleCancelOrClose); } - return await MessageBox.confirm(message, resolvedConfig).catch(handleCancelOrClose); + return await MessageBox.confirm(sanitizeIfString(message), resolvedConfig).catch( + handleCancelOrClose, + ); } async function prompt( @@ -70,11 +77,15 @@ export function useMessage() { }; if (typeof configOrTitle === 'string') { - return await MessageBox.prompt(message, configOrTitle, resolvedConfig).catch( - handleCancelOrClosePrompt, - ); + return await MessageBox.prompt( + sanitizeIfString(message), + sanitizeIfString(configOrTitle), + resolvedConfig, + ).catch(handleCancelOrClosePrompt); } - return await MessageBox.prompt(message, resolvedConfig).catch(handleCancelOrClosePrompt); + return await MessageBox.prompt(sanitizeIfString(message), resolvedConfig).catch( + handleCancelOrClosePrompt, + ); } return { diff --git a/packages/editor-ui/src/composables/useWorkflowHelpers.ts b/packages/editor-ui/src/composables/useWorkflowHelpers.ts index fd203a9ecc..2c79cb2639 100644 --- a/packages/editor-ui/src/composables/useWorkflowHelpers.ts +++ b/packages/editor-ui/src/composables/useWorkflowHelpers.ts @@ -693,6 +693,42 @@ export function useWorkflowHelpers(options: { router: ReturnType; let posthogStore: ReturnType; const apiSpy = vi.spyOn(chatAPI, 'chatWithAssistant'); +const track = vi.fn(); +const spy = vi.spyOn(telemetryModule, 'useTelemetry'); +spy.mockImplementation( + () => + ({ + track, + }) as unknown as Telemetry, +); + const setAssistantEnabled = (enabled: boolean) => { settingsStore.setSettings( merge({}, defaultSettings, { @@ -38,6 +49,7 @@ vi.mock('vue-router', () => ({ name: ENABLED_VIEWS[0], }), ), + useRouter: vi.fn(), RouterLink: vi.fn(), })); @@ -63,6 +75,7 @@ describe('AI Assistant store', () => { }; posthogStore = usePostHog(); posthogStore.init(); + track.mockReset(); }); it('initializes with default values', () => { @@ -316,4 +329,67 @@ describe('AI Assistant store', () => { await assistantStore.initErrorHelper(context); expect(apiSpy).toHaveBeenCalled(); }); + + it('should call telemetry for opening assistant with error', async () => { + const context: ChatRequest.ErrorContext = { + error: { + description: '', + message: 'Hey', + name: 'NodeOperationError', + }, + node: { + id: '1', + type: 'n8n-nodes-base.stopAndError', + typeVersion: 1, + name: 'Stop and Error', + position: [250, 250], + parameters: {}, + }, + }; + const mockSessionId = 'test'; + + const assistantStore = useAssistantStore(); + apiSpy.mockImplementation((_ctx, _payload, onMessage) => { + onMessage({ + messages: [], + sessionId: mockSessionId, + }); + }); + + await assistantStore.initErrorHelper(context); + expect(apiSpy).toHaveBeenCalled(); + expect(assistantStore.currentSessionId).toEqual(mockSessionId); + + assistantStore.trackUserOpenedAssistant({ + task: 'error', + source: 'error', + has_existing_session: true, + }); + expect(track).toHaveBeenCalledWith( + 'Assistant session started', + { + chat_session_id: 'test', + node_type: 'n8n-nodes-base.stopAndError', + task: 'error', + credential_type: undefined, + }, + { + withPostHog: true, + }, + ); + + expect(track).toHaveBeenCalledWith('User opened assistant', { + chat_session_id: 'test', + error: { + description: '', + message: 'Hey', + name: 'NodeOperationError', + }, + has_existing_session: true, + node_type: 'n8n-nodes-base.stopAndError', + source: 'error', + task: 'error', + workflow_id: '__EMPTY__', + }); + }); }); diff --git a/packages/editor-ui/src/stores/__tests__/posthog.test.ts b/packages/editor-ui/src/stores/__tests__/posthog.test.ts index e689a880c6..00805c5628 100644 --- a/packages/editor-ui/src/stores/__tests__/posthog.test.ts +++ b/packages/editor-ui/src/stores/__tests__/posthog.test.ts @@ -4,12 +4,12 @@ import { useUsersStore } from '@/stores/users.store'; import { useSettingsStore } from '@/stores/settings.store'; import { useRootStore } from '@/stores/root.store'; import { useTelemetryStore } from '@/stores/telemetry.store'; -import type { IN8nUISettings } from 'n8n-workflow'; +import type { FrontendSettings } from '@n8n/api-types'; import { LOCAL_STORAGE_EXPERIMENT_OVERRIDES } from '@/constants'; import { nextTick } from 'vue'; import { defaultSettings } from '../../__tests__/defaults'; -export const DEFAULT_POSTHOG_SETTINGS: IN8nUISettings['posthog'] = { +export const DEFAULT_POSTHOG_SETTINGS: FrontendSettings['posthog'] = { enabled: true, apiHost: 'host', apiKey: 'key', @@ -20,13 +20,13 @@ export const DEFAULT_POSTHOG_SETTINGS: IN8nUISettings['posthog'] = { const CURRENT_USER_ID = '1'; const CURRENT_INSTANCE_ID = '456'; -function setSettings(overrides?: Partial) { +function setSettings(overrides?: Partial) { useSettingsStore().setSettings({ ...defaultSettings, posthog: DEFAULT_POSTHOG_SETTINGS, instanceId: CURRENT_INSTANCE_ID, ...overrides, - } as IN8nUISettings); + } as FrontendSettings); useRootStore().setInstanceId(CURRENT_INSTANCE_ID); } diff --git a/packages/editor-ui/src/stores/__tests__/sso.test.ts b/packages/editor-ui/src/stores/__tests__/sso.test.ts index b21d5aeb1f..e40da12d91 100644 --- a/packages/editor-ui/src/stores/__tests__/sso.test.ts +++ b/packages/editor-ui/src/stores/__tests__/sso.test.ts @@ -3,12 +3,12 @@ import { useSettingsStore } from '@/stores/settings.store'; import { useSSOStore } from '@/stores/sso.store'; import { merge } from 'lodash-es'; import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; -import type { IN8nUISettings } from 'n8n-workflow'; +import type { FrontendSettings } from '@n8n/api-types'; let ssoStore: ReturnType; let settingsStore: ReturnType; -const DEFAULT_SETTINGS: IN8nUISettings = SETTINGS_STORE_DEFAULT_STATE.settings; +const DEFAULT_SETTINGS: FrontendSettings = SETTINGS_STORE_DEFAULT_STATE.settings; describe('SSO store', () => { beforeEach(() => { diff --git a/packages/editor-ui/src/stores/__tests__/workflowHistory.store.test.ts b/packages/editor-ui/src/stores/__tests__/workflowHistory.store.test.ts index e4b254f478..b146b9902f 100644 --- a/packages/editor-ui/src/stores/__tests__/workflowHistory.store.test.ts +++ b/packages/editor-ui/src/stores/__tests__/workflowHistory.store.test.ts @@ -1,5 +1,5 @@ import { createPinia, setActivePinia } from 'pinia'; -import type { IN8nUISettings } from 'n8n-workflow'; +import type { FrontendSettings } from '@n8n/api-types'; import { useWorkflowHistoryStore } from '@/stores/workflowHistory.store'; import { useSettingsStore } from '@/stores/settings.store'; @@ -26,7 +26,7 @@ describe('Workflow history store', () => { pruneTime, licensePruneTime, }, - } as IN8nUISettings; + } as FrontendSettings; expect(workflowHistoryStore.shouldUpgrade).toBe(shouldUpgrade); }, diff --git a/packages/editor-ui/src/stores/assistant.store.ts b/packages/editor-ui/src/stores/assistant.store.ts index 386df83adf..0ac0346e9c 100644 --- a/packages/editor-ui/src/stores/assistant.store.ts +++ b/packages/editor-ui/src/stores/assistant.store.ts @@ -17,7 +17,7 @@ import { useRoute } from 'vue-router'; import { useSettingsStore } from './settings.store'; import { assert } from '@/utils/assert'; import { useWorkflowsStore } from './workflows.store'; -import type { ICredentialType, INodeParameters } from 'n8n-workflow'; +import type { IDataObject, ICredentialType, INodeParameters } from 'n8n-workflow'; import { deepCopy } from 'n8n-workflow'; import { ndvEventBus, codeNodeEditorEventBus } from '@/event-bus'; import { useNDVStore } from './ndv.store'; @@ -27,7 +27,8 @@ import { getNodeAuthOptions, getReferencedNodes, getNodesSchemas, - pruneNodeProperties, + processNodeForAssistant, + isNodeReferencingInputData, } from '@/utils/nodeTypesUtils'; import { useNodeTypesStore } from './nodeTypes.store'; import { usePostHog } from './posthog.store'; @@ -421,6 +422,16 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => { const availableAuthOptions = getNodeAuthOptions(nodeType); authType = availableAuthOptions.find((option) => option.value === credentialInUse); } + let nodeInputData: { inputNodeName?: string; inputData?: IDataObject } | undefined = undefined; + const ndvInput = ndvStore.ndvInputData; + if (isNodeReferencingInputData(context.node) && ndvInput?.length) { + const inputData = ndvStore.ndvInputData[0].json; + const inputNodeName = ndvStore.input.nodeName; + nodeInputData = { + inputNodeName, + inputData, + }; + } addLoadingAssistantMessage(locale.baseText('aiAssistant.thinkingSteps.analyzingError')); openChat(); @@ -435,7 +446,8 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => { firstName: usersStore.currentUser?.firstName ?? '', }, error: context.error, - node: pruneNodeProperties(context.node, ['position']), + node: processNodeForAssistant(context.node, ['position']), + nodeInputData, executionSchema: schemas, authType, }, @@ -580,7 +592,7 @@ export const useAssistantStore = defineStore(STORES.ASSISTANT, () => { workflow_id: workflowsStore.workflowId, node_type: chatSessionError.value?.node?.type, error: chatSessionError.value?.error, - chat_session_id: currentSessionId, + chat_session_id: currentSessionId.value, }); } diff --git a/packages/editor-ui/src/stores/credentials.store.ts b/packages/editor-ui/src/stores/credentials.store.ts index 663303572b..ec71aa5b29 100644 --- a/packages/editor-ui/src/stores/credentials.store.ts +++ b/packages/editor-ui/src/stores/credentials.store.ts @@ -189,7 +189,7 @@ export const useCredentialsStore = defineStore(STORES.CREDENTIALS, () => { ? email ? `${name} (${email})` : name - : email ?? i18n.baseText('credentialEdit.credentialSharing.info.sharee.fallback'); + : (email ?? i18n.baseText('credentialEdit.credentialSharing.info.sharee.fallback')); }; }); diff --git a/packages/editor-ui/src/stores/ndv.store.ts b/packages/editor-ui/src/stores/ndv.store.ts index 1e7d5da363..e3be5db5fe 100644 --- a/packages/editor-ui/src/stores/ndv.store.ts +++ b/packages/editor-ui/src/stores/ndv.store.ts @@ -91,7 +91,7 @@ export const useNDVStore = defineStore(STORES.NDV, { ndvInputDataWithPinnedData(): INodeExecutionData[] { const data = this.ndvInputData; return this.ndvInputNodeName - ? useWorkflowsStore().pinDataByNodeName(this.ndvInputNodeName) ?? data + ? (useWorkflowsStore().pinDataByNodeName(this.ndvInputNodeName) ?? data) : data; }, hasInputData(): boolean { diff --git a/packages/editor-ui/src/stores/settings.store.ts b/packages/editor-ui/src/stores/settings.store.ts index 2886aff1ba..69cd665e84 100644 --- a/packages/editor-ui/src/stores/settings.store.ts +++ b/packages/editor-ui/src/stores/settings.store.ts @@ -1,5 +1,6 @@ import { computed, ref } from 'vue'; import Bowser from 'bowser'; +import type { IUserManagementSettings, FrontendSettings } from '@n8n/api-types'; import * as publicApiApi from '@/api/api-keys'; import * as ldapApi from '@/api/ldap'; @@ -8,12 +9,7 @@ import { testHealthEndpoint } from '@/api/templates'; import type { ILdapConfig } from '@/Interface'; import { STORES, INSECURE_CONNECTION_WARNING } from '@/constants'; import { UserManagementAuthenticationMethod } from '@/Interface'; -import type { - IDataObject, - IN8nUISettings, - WorkflowSettings, - IUserManagementSettings, -} from 'n8n-workflow'; +import type { IDataObject, WorkflowSettings } from 'n8n-workflow'; import { ExpressionEvaluatorProxy } from 'n8n-workflow'; import { defineStore } from 'pinia'; import { useRootStore } from './root.store'; @@ -27,7 +23,7 @@ import { i18n } from '@/plugins/i18n'; export const useSettingsStore = defineStore(STORES.SETTINGS, () => { const initialized = ref(false); - const settings = ref({} as IN8nUISettings); + const settings = ref({} as FrontendSettings); const userManagement = ref({ quota: -1, showSetupOnFirstLoad: false, @@ -164,7 +160,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { const isDevRelease = computed(() => settings.value.releaseChannel === 'dev'); - const setSettings = (newSettings: IN8nUISettings) => { + const setSettings = (newSettings: FrontendSettings) => { settings.value = newSettings; userManagement.value = newSettings.userManagement; if (userManagement.value) { @@ -208,7 +204,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { } }; - const setAllowedModules = (allowedModules: IN8nUISettings['allowedModules']) => { + const setAllowedModules = (allowedModules: FrontendSettings['allowedModules']) => { settings.value.allowedModules = allowedModules; }; @@ -367,7 +363,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { }; const reset = () => { - settings.value = {} as IN8nUISettings; + settings.value = {} as FrontendSettings; }; return { diff --git a/packages/editor-ui/src/stores/versions.store.ts b/packages/editor-ui/src/stores/versions.store.ts index 9f742a12cf..8455d5d15f 100644 --- a/packages/editor-ui/src/stores/versions.store.ts +++ b/packages/editor-ui/src/stores/versions.store.ts @@ -1,6 +1,7 @@ +import type { IVersionNotificationSettings } from '@n8n/api-types'; import * as versionsApi from '@/api/versions'; import { STORES, VERSIONS_MODAL_KEY } from '@/constants'; -import type { IVersion, IVersionNotificationSettings } from '@/Interface'; +import type { IVersion } from '@/Interface'; import { defineStore } from 'pinia'; import { useRootStore } from './root.store'; import { useToast } from '@/composables/useToast'; diff --git a/packages/editor-ui/src/stores/workflows.ee.store.ts b/packages/editor-ui/src/stores/workflows.ee.store.ts index 18bb81cdf2..de3589f9fe 100644 --- a/packages/editor-ui/src/stores/workflows.ee.store.ts +++ b/packages/editor-ui/src/stores/workflows.ee.store.ts @@ -21,7 +21,7 @@ export const useWorkflowsEEStore = defineStore(STORES.WORKFLOWS_EE, { const workflow = useWorkflowsStore().getWorkflowById(workflowId); const { name, email } = splitName(workflow?.homeProject?.name ?? ''); - return name ? (email ? `${name} (${email})` : name) : email ?? fallback; + return name ? (email ? `${name} (${email})` : name) : (email ?? fallback); }; }, }, diff --git a/packages/editor-ui/src/styles/plugins/index.scss b/packages/editor-ui/src/styles/plugins/index.scss index c5d099d5d1..9914829eea 100644 --- a/packages/editor-ui/src/styles/plugins/index.scss +++ b/packages/editor-ui/src/styles/plugins/index.scss @@ -1,2 +1,2 @@ -@import "codemirror"; -@import "vueflow"; +@import 'codemirror'; +@import 'vueflow'; diff --git a/packages/editor-ui/src/types/assistant.types.ts b/packages/editor-ui/src/types/assistant.types.ts index 69749cee73..1b6ea0a089 100644 --- a/packages/editor-ui/src/types/assistant.types.ts +++ b/packages/editor-ui/src/types/assistant.types.ts @@ -1,8 +1,8 @@ import type { Schema } from '@/Interface'; -import type { INode, INodeParameters } from 'n8n-workflow'; +import type { IDataObject, INode, INodeParameters } from 'n8n-workflow'; export namespace ChatRequest { - interface NodeExecutionSchema { + export interface NodeExecutionSchema { nodeName: string; schema: Schema; } @@ -21,6 +21,7 @@ export namespace ChatRequest { stack?: string; }; node: INode; + nodeInputData?: IDataObject; } export interface InitErrorHelper extends ErrorContext, WorkflowContext { diff --git a/packages/editor-ui/src/types/externalHooks.ts b/packages/editor-ui/src/types/externalHooks.ts index 005762e759..d2c8b01821 100644 --- a/packages/editor-ui/src/types/externalHooks.ts +++ b/packages/editor-ui/src/types/externalHooks.ts @@ -302,8 +302,10 @@ export type ExternalHooksKey = { [K in keyof ExternalHooks]: `${K}.${Extract}`; }[keyof ExternalHooks]; -type ExtractHookMethodArray

= - ExternalHooks[P][S] extends Array ? U : never; +type ExtractHookMethodArray< + P extends keyof ExternalHooks, + S extends keyof ExternalHooks[P], +> = ExternalHooks[P][S] extends Array ? U : never; type ExtractHookMethodFunction = T extends ExternalHooksMethod ? T : never; diff --git a/packages/editor-ui/src/utils/__tests__/nodeTypesUtils.spec.ts b/packages/editor-ui/src/utils/__tests__/nodeTypesUtils.spec.ts new file mode 100644 index 0000000000..118095ed24 --- /dev/null +++ b/packages/editor-ui/src/utils/__tests__/nodeTypesUtils.spec.ts @@ -0,0 +1,383 @@ +import { describe, it, expect } from 'vitest'; +import { getReferencedNodes } from '../nodeTypesUtils'; +import type { INode } from 'n8n-workflow'; + +const referencedNodesTestCases: Array<{ caseName: string; node: INode; expected: string[] }> = [ + { + caseName: 'Should return an empty array if no referenced nodes', + node: { + parameters: { + curlImport: '', + method: 'GET', + url: 'https://httpbin.org/get1', + authentication: 'none', + provideSslCertificates: false, + sendQuery: false, + sendHeaders: false, + sendBody: false, + options: {}, + infoMessage: '', + }, + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [220, 220], + id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5', + name: 'HTTP Request', + }, + expected: [], + }, + { + caseName: 'Should return an array of references for regular node', + node: { + parameters: { + authentication: 'oAuth2', + resource: 'sheet', + operation: 'read', + documentId: { + __rl: true, + value: "={{ $('Edit Fields').item.json.document }}", + mode: 'id', + }, + sheetName: { + __rl: true, + value: "={{ $('Edit Fields 2').item.json.sheet }}", + mode: 'id', + }, + filtersUI: {}, + combineFilters: 'AND', + options: {}, + }, + type: 'n8n-nodes-base.googleSheets', + typeVersion: 4.5, + position: [440, 0], + id: '9a95ad27-06cf-4076-af6b-52846a109a8b', + name: 'Google Sheets', + credentials: { + googleSheetsOAuth2Api: { + id: '8QEpi028oHDLXntS', + name: 'milorad@n8n.io', + }, + }, + }, + expected: ['Edit Fields', 'Edit Fields 2'], + }, + { + caseName: 'Should return an array of references for set node', + node: { + parameters: { + mode: 'manual', + duplicateItem: false, + assignments: { + assignments: [ + { + id: '135e0eb0-f412-430d-8990-731c57cf43ae', + name: 'document', + value: "={{ $('Edit Fields 2').item.json.document}}", + type: 'string', + }, + ], + }, + includeOtherFields: false, + options: {}, + }, + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [560, -140], + id: '7306745f-ba8c-451d-ae1a-c627f60fbdd3', + name: 'Edit Fields 2', + }, + expected: ['Edit Fields 2'], + }, + { + caseName: 'Should handle expressions with single quotes, double quotes and backticks', + node: { + parameters: { + authentication: 'oAuth2', + resource: 'sheet', + operation: 'read', + documentId: { + __rl: true, + value: "={{ $('Edit Fields').item.json.document }}", + mode: 'id', + }, + sheetName: { + __rl: true, + value: '={{ $("Edit Fields 2").item.json.sheet }}', + mode: 'id', + }, + rowName: { + __rl: true, + value: '={{ $(`Edit Fields 3`).item.json.row }}', + mode: 'id', + }, + filtersUI: {}, + combineFilters: 'AND', + options: {}, + }, + type: 'n8n-nodes-base.googleSheets', + typeVersion: 4.5, + position: [440, 0], + id: '9a95ad27-06cf-4076-af6b-52846a109a8b', + name: 'Google Sheets', + credentials: { + googleSheetsOAuth2Api: { + id: '8QEpi028oHDLXntS', + name: 'milorad@n8n.io', + }, + }, + }, + expected: ['Edit Fields', 'Edit Fields 2', 'Edit Fields 3'], + }, + { + caseName: 'Should only add one reference for each referenced node', + node: { + parameters: { + authentication: 'oAuth2', + resource: 'sheet', + operation: 'read', + documentId: { + __rl: true, + value: "={{ $('Edit Fields').item.json.document }}", + mode: 'id', + }, + sheetName: { + __rl: true, + value: "={{ $('Edit Fields').item.json.sheet }}", + mode: 'id', + }, + filtersUI: {}, + combineFilters: 'AND', + options: {}, + }, + type: 'n8n-nodes-base.googleSheets', + typeVersion: 4.5, + position: [440, 0], + id: '9a95ad27-06cf-4076-af6b-52846a109a8b', + name: 'Google Sheets', + credentials: { + googleSheetsOAuth2Api: { + id: '8QEpi028oHDLXntS', + name: 'milorad@n8n.io', + }, + }, + }, + expected: ['Edit Fields'], + }, + { + caseName: 'Should handle multiple node references in one expression', + node: { + parameters: { + curlImport: '', + method: 'GET', + url: "={{ $('Edit Fields').item.json.one }} {{ $('Edit Fields 2').item.json.two }} {{ $('Edit Fields').item.json.three }}", + authentication: 'none', + provideSslCertificates: false, + sendQuery: false, + sendHeaders: false, + sendBody: false, + options: {}, + infoMessage: '', + }, + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [220, 220], + id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5', + name: 'HTTP Request', + }, + expected: ['Edit Fields', 'Edit Fields 2'], + }, + { + caseName: 'Should respect whitespace around node references', + node: { + parameters: { + curlImport: '', + method: 'GET', + url: "={{ $(' Edit Fields ').item.json.one }}", + authentication: 'none', + provideSslCertificates: false, + sendQuery: false, + sendHeaders: false, + sendBody: false, + options: {}, + infoMessage: '', + }, + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [220, 220], + id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5', + name: 'HTTP Request', + }, + expected: [' Edit Fields '], + }, + { + caseName: 'Should ignore whitespace inside expressions', + node: { + parameters: { + curlImport: '', + method: 'GET', + url: "={{ $( 'Edit Fields' ).item.json.one }}", + authentication: 'none', + provideSslCertificates: false, + sendQuery: false, + sendHeaders: false, + sendBody: false, + options: {}, + infoMessage: '', + }, + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [220, 220], + id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5', + name: 'HTTP Request', + }, + expected: ['Edit Fields'], + }, + { + caseName: 'Should ignore special characters in node references', + node: { + parameters: { + curlImport: '', + method: 'GET', + url: "={{ $( 'Ignore ' this' ).item.json.document }", + authentication: 'none', + provideSslCertificates: false, + sendQuery: false, + sendHeaders: false, + sendBody: false, + options: {}, + infoMessage: '', + }, + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [220, 220], + id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5', + name: 'HTTP Request', + }, + expected: [], + }, + { + caseName: 'Should correctly detect node names that contain single quotes', + node: { + parameters: { + curlImport: '', + method: 'GET', + // In order to carry over backslashes to test function, the string needs to be double escaped + url: "={{ $('Edit \\'Fields\\' 2').item.json.name }}", + authentication: 'none', + provideSslCertificates: false, + sendQuery: false, + sendHeaders: false, + sendBody: false, + options: {}, + infoMessage: '', + }, + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [220, 220], + id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5', + name: 'HTTP Request', + }, + expected: ["Edit 'Fields' 2"], + }, + { + caseName: 'Should correctly detect node names with inner backticks', + node: { + parameters: { + curlImport: '', + method: 'GET', + url: "={{ $('Edit `Fields` 2').item.json.name }}", + authentication: 'none', + provideSslCertificates: false, + sendQuery: false, + sendHeaders: false, + sendBody: false, + options: {}, + infoMessage: '', + }, + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [220, 220], + id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5', + name: 'HTTP Request', + }, + expected: ['Edit `Fields` 2'], + }, + { + caseName: 'Should correctly detect node names with inner escaped backticks', + node: { + parameters: { + curlImport: '', + method: 'GET', + url: '={{ $(`Edit \\`Fields\\` 2`).item.json.name }}', + authentication: 'none', + provideSslCertificates: false, + sendQuery: false, + sendHeaders: false, + sendBody: false, + options: {}, + infoMessage: '', + }, + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [220, 220], + id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5', + name: 'HTTP Request', + }, + expected: ['Edit `Fields` 2'], + }, + { + caseName: 'Should correctly detect node names with inner escaped double quotes', + node: { + parameters: { + curlImport: '', + method: 'GET', + // In order to carry over backslashes to test function, the string needs to be double escaped + url: '={{ $("Edit \\"Fields\\" 2").item.json.name }}', + authentication: 'none', + provideSslCertificates: false, + sendQuery: false, + sendHeaders: false, + sendBody: false, + options: {}, + infoMessage: '', + }, + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [220, 220], + id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5', + name: 'HTTP Request', + }, + expected: ['Edit "Fields" 2'], + }, + { + caseName: 'Should not detect invalid expressions', + node: { + parameters: { + curlImport: '', + method: 'GET', + // String not closed properly + url: "={{ $('Edit ' fields').item.json.document }", + // Mixed quotes + url2: '{{ $("Edit \'Fields" 2").item.json.name }}', + url3: '{{ $("Edit `Fields" 2").item.json.name }}', + // Quotes not escaped + url4: '{{ $("Edit "Fields" 2").item.json.name }}', + url5: "{{ $('Edit 'Fields' 2').item.json.name }}", + url6: '{{ $(`Edit `Fields` 2`).item.json.name }}', + }, + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [220, 220], + id: 'edc36001-aee7-4052-b66e-cf127f4b6ea5', + name: 'HTTP Request', + }, + expected: [], + }, +]; + +describe.each(referencedNodesTestCases)('getReferencedNodes', (testCase) => { + const caseName = testCase.caseName; + it(`${caseName}`, () => { + expect(getReferencedNodes(testCase.node)).toEqual(testCase.expected); + }); +}); diff --git a/packages/editor-ui/src/utils/htmlUtils.ts b/packages/editor-ui/src/utils/htmlUtils.ts index 5da34b3965..2a78582b01 100644 --- a/packages/editor-ui/src/utils/htmlUtils.ts +++ b/packages/editor-ui/src/utils/htmlUtils.ts @@ -37,6 +37,17 @@ export function sanitizeHtml(dirtyHtml: string) { return sanitizedHtml; } +/** + * Checks if the input is a string and sanitizes it by removing or escaping harmful characters, + * returning the original input if it's not a string. + */ +export const sanitizeIfString = (message: T): string | T => { + if (typeof message === 'string') { + return sanitizeHtml(message); + } + return message; +}; + export function setPageTitle(title: string) { window.document.title = title; } diff --git a/packages/editor-ui/src/utils/nodeTypesUtils.ts b/packages/editor-ui/src/utils/nodeTypesUtils.ts index 70fa803411..03f930c740 100644 --- a/packages/editor-ui/src/utils/nodeTypesUtils.ts +++ b/packages/editor-ui/src/utils/nodeTypesUtils.ts @@ -5,10 +5,10 @@ import type { ITemplatesNode, IVersionNode, NodeAuthenticationOption, - Schema, SimplifiedNodeType, } from '@/Interface'; import { useDataSchema } from '@/composables/useDataSchema'; +import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { CORE_NODES_CATEGORY, MAIN_AUTH_FIELD_NAME, @@ -20,20 +20,22 @@ import { i18n as locale } from '@/plugins/i18n'; import { useCredentialsStore } from '@/stores/credentials.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; +import type { ChatRequest } from '@/types/assistant.types'; import { isResourceLocatorValue } from '@/utils/typeGuards'; import { isJsonKeyObject } from '@/utils/typesUtils'; -import type { - AssignmentCollectionValue, - IDataObject, - INode, - INodeCredentialDescription, - INodeExecutionData, - INodeProperties, - INodeTypeDescription, - NodeParameterValueType, - ResourceMapperField, - Themed, +import { + deepCopy, + type IDataObject, + type INode, + type INodeCredentialDescription, + type INodeExecutionData, + type INodeProperties, + type INodeTypeDescription, + type NodeParameterValueType, + type ResourceMapperField, + type Themed, } from 'n8n-workflow'; +import { useRouter } from 'vue-router'; /* Constants and utility functions mainly used to get information about @@ -503,9 +505,9 @@ export const getNodeIconColor = ( /** Regular expression to extract the node names from the expressions in the template. - Example: $(expression) => expression + Supports single quotes, double quotes, and backticks. */ -const entityRegex = /\$\((['"])(.*?)\1\)/g; +const entityRegex = /\$\(\s*(\\?["'`])((?:\\.|(?!\1)[^\\])*)\1\s*\)/g; /** * Extract the node names from the expressions in the template. @@ -520,81 +522,89 @@ function extractNodeNames(template: string): string[] { } /** - * Extract the node names from the expressions in the node parameters. + * Unescape quotes in the string. Supports single quotes, double quotes, and backticks. */ -export function getReferencedNodes(node: INode): string[] { - const referencedNodes: string[] = []; - if (!node) { - return referencedNodes; - } - // Special case for code node - if (node.type === 'n8n-nodes-base.set' && node.parameters.assignments) { - const assignments = node.parameters.assignments as AssignmentCollectionValue; - if (assignments.assignments?.length) { - assignments.assignments.forEach((assignment) => { - if (assignment.name && assignment.value && String(assignment.value).startsWith('=')) { - const nodeNames = extractNodeNames(String(assignment.value)); - if (nodeNames.length) { - referencedNodes.push(...nodeNames); - } - } - }); - } - } else { - Object.values(node.parameters).forEach((value) => { - if (!value) { - return; - } - let strValue = String(value); - // Handle resource locator - if (typeof value === 'object' && 'value' in value) { - strValue = String(value.value); - } - if (strValue.startsWith('=')) { - const nodeNames = extractNodeNames(strValue); - if (nodeNames.length) { - referencedNodes.push(...nodeNames); - } - } - }); - } - return referencedNodes; +export function unescapeQuotes(str: string): string { + return str.replace(/\\(['"`])/g, '$1'); } /** - * Remove properties from a node based on the provided list of property names. - * Reruns a new node object with the properties removed. + * Extract the node names from the expressions in the node parameters. */ -export function pruneNodeProperties(node: INode, propsToRemove: string[]): INode { - const prunedNode = { ...node }; +export function getReferencedNodes(node: INode): string[] { + const referencedNodes: Set = new Set(); + if (!node) { + return []; + } + // Go through all parameters and check if they contain expressions on any level + for (const key in node.parameters) { + let names: string[] = []; + if ( + node.parameters[key] && + typeof node.parameters[key] === 'object' && + Object.keys(node.parameters[key]).length + ) { + names = extractNodeNames(JSON.stringify(node.parameters[key])); + } else if (typeof node.parameters[key] === 'string' && node.parameters[key]) { + names = extractNodeNames(node.parameters[key]); + } + if (names.length) { + names + .map((name) => unescapeQuotes(name)) + .forEach((name) => { + referencedNodes.add(name); + }); + } + } + return referencedNodes.size ? Array.from(referencedNodes) : []; +} + +/** + * Processes node object before sending it to AI assistant + * - Removes unnecessary properties + * - Extracts expressions from the parameters and resolves them + * @param node original node object + * @param propsToRemove properties to remove from the node object + * @returns processed node + */ +export function processNodeForAssistant(node: INode, propsToRemove: string[]): INode { + // Make a copy of the node object so we don't modify the original + const nodeForLLM = deepCopy(node); propsToRemove.forEach((key) => { - delete prunedNode[key as keyof INode]; + delete nodeForLLM[key as keyof INode]; }); - return prunedNode; + const workflowHelpers = useWorkflowHelpers({ router: useRouter() }); + const resolvedParameters = workflowHelpers.getNodeParametersWithResolvedExpressions( + nodeForLLM.parameters, + ); + nodeForLLM.parameters = resolvedParameters; + return nodeForLLM; +} + +export function isNodeReferencingInputData(node: INode): boolean { + const parametersString = JSON.stringify(node.parameters); + const references = ['$json', '$input', '$binary']; + return references.some((ref) => parametersString.includes(ref)); } /** * Get the schema for the referenced nodes as expected by the AI assistant * @param nodeNames The names of the nodes to get the schema for - * @returns An array of objects containing the node name and the schema + * @returns An array of NodeExecutionSchema objects */ export function getNodesSchemas(nodeNames: string[]) { - return nodeNames.map((name) => { + const schemas: ChatRequest.NodeExecutionSchema[] = []; + for (const name of nodeNames) { const node = useWorkflowsStore().getNodeByName(name); if (!node) { - return { - nodeName: name, - schema: {} as Schema, - }; + continue; } const { getSchemaForExecutionData, getInputDataWithPinned } = useDataSchema(); - const schema = getSchemaForExecutionData( - executionDataToJson(getInputDataWithPinned(node)), - true, - ); - return { + const schema = getSchemaForExecutionData(executionDataToJson(getInputDataWithPinned(node))); + schemas.push({ nodeName: node.name, schema, - }; - }); + }); + } + return schemas; } diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index b22ce73d3d..a792ff135a 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -2926,6 +2926,7 @@ export default defineComponent({ } if ( + // @ts-expect-error Deprecated file // eslint-disable-next-line no-constant-binary-expression !(this.workflowPermissions.update ?? this.projectPermissions.workflow.update) ?? this.isReadOnlyRoute ?? @@ -2965,6 +2966,7 @@ export default defineComponent({ } if ( + // @ts-expect-error Deprecated file // eslint-disable-next-line no-constant-binary-expression !(this.workflowPermissions.update ?? this.projectPermissions.workflow.update) ?? this.isReadOnlyRoute ?? diff --git a/packages/editor-ui/src/views/ProjectSettings.test.ts b/packages/editor-ui/src/views/ProjectSettings.test.ts index 13a8707109..333480665d 100644 --- a/packages/editor-ui/src/views/ProjectSettings.test.ts +++ b/packages/editor-ui/src/views/ProjectSettings.test.ts @@ -10,7 +10,7 @@ import { VIEWS } from '@/constants'; import { useUsersStore } from '@/stores/users.store'; import { createProjectListItem } from '@/__tests__/data/projects'; import { useSettingsStore } from '@/stores/settings.store'; -import type { IN8nUISettings } from 'n8n-workflow'; +import type { FrontendSettings } from '@n8n/api-types'; import { ProjectTypes } from '@/types/projects.types'; vi.mock('vue-router', () => { @@ -63,7 +63,7 @@ describe('ProjectSettings', () => { }, }, }, - } as IN8nUISettings); + } as FrontendSettings); projectsStore.setCurrentProject({ id: '123', type: 'team', diff --git a/packages/editor-ui/src/views/SettingsLdapView.vue b/packages/editor-ui/src/views/SettingsLdapView.vue index 8feca49da7..6cab6b1ba5 100644 --- a/packages/editor-ui/src/views/SettingsLdapView.vue +++ b/packages/editor-ui/src/views/SettingsLdapView.vue @@ -633,7 +633,7 @@ export default defineComponent({

- +
diff --git a/packages/editor-ui/src/views/SettingsLogStreamingView.vue b/packages/editor-ui/src/views/SettingsLogStreamingView.vue index e8abb67e64..0f62a31aee 100644 --- a/packages/editor-ui/src/views/SettingsLogStreamingView.vue +++ b/packages/editor-ui/src/views/SettingsLogStreamingView.vue @@ -175,7 +175,7 @@ export default defineComponent({