diff --git a/.github/scripts/package.json b/.github/scripts/package.json
index cd67711fa4..80bf0baf11 100644
--- a/.github/scripts/package.json
+++ b/.github/scripts/package.json
@@ -5,7 +5,7 @@
"debug": "4.3.4",
"glob": "10.3.10",
"p-limit": "3.1.0",
- "picocolors": "1.0.0",
+ "picocolors": "1.0.1",
"semver": "7.5.4",
"tempfile": "5.0.0",
"typescript": "*"
diff --git a/.github/workflows/check-pr-title.yml b/.github/workflows/check-pr-title.yml
index add6e858a3..761cc5b8c7 100644
--- a/.github/workflows/check-pr-title.yml
+++ b/.github/workflows/check-pr-title.yml
@@ -7,8 +7,7 @@ on:
- edited
- synchronize
branches:
- - '**'
- - '!release/*'
+ - 'master'
jobs:
check-pr-title:
diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml
index bfd43e9257..d2ebc05320 100644
--- a/.github/workflows/chromatic.yml
+++ b/.github/workflows/chromatic.yml
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
pull_request_review:
types: [submitted]
- branch:
+ branches:
- 'master'
paths:
- packages/design-system/**
diff --git a/.github/workflows/ci-postgres-mysql.yml b/.github/workflows/ci-postgres-mysql.yml
index d5abf1fa26..1d645ae774 100644
--- a/.github/workflows/ci-postgres-mysql.yml
+++ b/.github/workflows/ci-postgres-mysql.yml
@@ -8,6 +8,10 @@ on:
paths:
- packages/cli/src/databases/**
- .github/workflows/ci-postgres-mysql.yml
+ pull_request_review:
+ types: [submitted]
+ branches:
+ - 'release/*'
concurrency:
group: db-${{ github.event.pull_request.number || github.ref }}
diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml
index 78a5388950..d9938983b1 100644
--- a/.github/workflows/ci-pull-requests.yml
+++ b/.github/workflows/ci-pull-requests.yml
@@ -1,6 +1,10 @@
name: Build, unit test and lint branch
-on: [pull_request]
+on:
+ pull_request:
+ branches:
+ - '**'
+ - '!release/*'
jobs:
install-and-build:
@@ -9,7 +13,6 @@ jobs:
steps:
- uses: actions/checkout@v4.1.1
with:
- repository: n8n-io/n8n
ref: refs/pull/${{ github.event.pull_request.number }}/merge
- run: corepack enable
diff --git a/.github/workflows/docker-images-benchmark.yml b/.github/workflows/docker-images-benchmark.yml
new file mode 100644
index 0000000000..cf9d7359a5
--- /dev/null
+++ b/.github/workflows/docker-images-benchmark.yml
@@ -0,0 +1,43 @@
+name: Benchmark Docker Image CI
+
+on:
+ workflow_dispatch:
+ push:
+ branches:
+ - main
+ paths:
+ - 'packages/@n8n/benchmark/**'
+ - 'pnpm-lock.yaml'
+ - 'pnpm-workspace.yaml'
+ - '.github/workflows/docker-images-benchmark.yml'
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4.1.1
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3.0.0
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3.0.0
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3.0.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build
+ uses: docker/build-push-action@v5.1.0
+ with:
+ context: .
+ file: ./packages/@n8n/benchmark/Dockerfile
+ platforms: linux/amd64
+ provenance: false
+ push: true
+ tags: |
+ ghcr.io/${{ github.repository_owner }}/n8n-benchmark:latest
diff --git a/.github/workflows/docker-images-nightly.yml b/.github/workflows/docker-images-nightly.yml
index b701392e8b..34950d7ac2 100644
--- a/.github/workflows/docker-images-nightly.yml
+++ b/.github/workflows/docker-images-nightly.yml
@@ -6,10 +6,6 @@ on:
- cron: '0 1 * * *'
workflow_dispatch:
inputs:
- repository:
- description: 'GitHub repository to create image off.'
- required: true
- default: 'n8n-io/n8n'
branch:
description: 'GitHub branch to create image off.'
required: true
@@ -49,7 +45,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v4.1.1
with:
- repository: ${{ github.event.inputs.repository || 'n8n-io/n8n' }}
ref: ${{ github.event.inputs.branch || 'master' }}
- name: Set up QEMU
diff --git a/.github/workflows/docker-images.yml b/.github/workflows/docker-images.yml
deleted file mode 100644
index 5c91f3832d..0000000000
--- a/.github/workflows/docker-images.yml
+++ /dev/null
@@ -1,48 +0,0 @@
-name: Docker Image CI
-
-on:
- release:
- types: [published]
-
-jobs:
- build:
- runs-on: ubuntu-latest
-
- steps:
- - uses: actions/checkout@v4.1.1
-
- - name: Get the version
- id: vars
- run: echo ::set-output name=tag::$(echo ${GITHUB_REF:14})
-
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3.0.0
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3.0.0
-
- - name: Login to GitHub Container Registry
- uses: docker/login-action@v3.0.0
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Login to DockerHub
- uses: docker/login-action@v3.0.0
- with:
- username: ${{ secrets.DOCKER_USERNAME }}
- password: ${{ secrets.DOCKER_PASSWORD }}
-
- - name: Build
- uses: docker/build-push-action@v5.1.0
- with:
- context: ./docker/images/n8n
- build-args: |
- N8N_VERSION=${{ steps.vars.outputs.tag }}
- platforms: linux/amd64,linux/arm64
- provenance: false
- push: true
- tags: |
- ${{ secrets.DOCKER_USERNAME }}/n8n:${{ steps.vars.outputs.tag }}
- ghcr.io/${{ github.repository_owner }}/n8n:${{ steps.vars.outputs.tag }}
diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml
index beea3bca27..ab88930a3a 100644
--- a/.github/workflows/e2e-reusable.yml
+++ b/.github/workflows/e2e-reusable.yml
@@ -22,11 +22,6 @@ on:
required: false
default: 'browsers:node18.12.0-chrome107'
type: string
- cache-key:
- description: 'Cache key for modules and build artifacts.'
- required: false
- default: ${{ github.sha }}-${{ inputs.run-env }}-e2e-modules
- type: string
record:
description: 'Record test run.'
required: false
@@ -78,7 +73,6 @@ jobs:
steps:
- uses: actions/checkout@v4.1.1
with:
- repository: n8n-io/n8n
ref: ${{ inputs.branch }}
- name: Checkout PR
@@ -111,7 +105,7 @@ jobs:
/github/home/.cache
/github/home/.pnpm-store
./packages/**/dist
- key: ${{ inputs.cache-key }}
+ key: ${{ github.sha }}-e2e
testing:
runs-on: ubuntu-latest
@@ -128,7 +122,6 @@ jobs:
steps:
- uses: actions/checkout@v4.1.1
with:
- repository: n8n-io/n8n
ref: ${{ inputs.branch }}
- name: Checkout PR
@@ -146,7 +139,7 @@ jobs:
/github/home/.cache
/github/home/.pnpm-store
./packages/**/dist
- key: ${{ inputs.cache-key }}
+ key: ${{ github.sha }}-e2e
- name: Install dependencies
run: pnpm install --frozen-lockfile
diff --git a/.github/workflows/e2e-tests-pr.yml b/.github/workflows/e2e-tests-pr.yml
index f845dbb062..3d2f122638 100644
--- a/.github/workflows/e2e-tests-pr.yml
+++ b/.github/workflows/e2e-tests-pr.yml
@@ -3,8 +3,9 @@ name: PR E2E
on:
pull_request_review:
types: [submitted]
- branch:
+ branches:
- 'master'
+ - 'release/*'
concurrency:
group: e2e-${{ github.event.pull_request.number || github.ref }}
@@ -18,7 +19,6 @@ jobs:
with:
pr_number: ${{ github.event.pull_request.number }}
user: ${{ github.event.pull_request.user.login || 'PR User' }}
- spec: 'e2e/*'
secrets:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
diff --git a/.github/workflows/linting-reusable.yml b/.github/workflows/linting-reusable.yml
index 2650622bd0..ed8d234940 100644
--- a/.github/workflows/linting-reusable.yml
+++ b/.github/workflows/linting-reusable.yml
@@ -21,7 +21,6 @@ jobs:
steps:
- uses: actions/checkout@v4.1.1
with:
- repository: n8n-io/n8n
ref: ${{ inputs.ref }}
- run: corepack enable
diff --git a/.github/workflows/release-create-pr.yml b/.github/workflows/release-create-pr.yml
index a17fa1bf89..03572e541c 100644
--- a/.github/workflows/release-create-pr.yml
+++ b/.github/workflows/release-create-pr.yml
@@ -56,12 +56,12 @@ jobs:
git push -f origin refs/remotes/origin/${{ github.event.inputs.base-branch }}:refs/heads/release/${{ env.NEXT_RELEASE }}
- name: Push the release branch, and Create the PR
- uses: peter-evans/create-pull-request@v5
+ uses: peter-evans/create-pull-request@v6
with:
base: 'release/${{ env.NEXT_RELEASE }}'
- branch: '${{ env.NEXT_RELEASE }}-pr'
+ branch: 'release-pr/${{ env.NEXT_RELEASE }}'
commit-message: ':rocket: Release ${{ env.NEXT_RELEASE }}'
delete-branch: true
- labels: 'release'
+ labels: release,release:${{ github.event.inputs.release-type }}
title: ':rocket: Release ${{ env.NEXT_RELEASE }}'
body-path: 'CHANGELOG-${{ env.NEXT_RELEASE }}.md'
diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml
index 239c18b512..522d47ff0f 100644
--- a/.github/workflows/release-publish.yml
+++ b/.github/workflows/release-publish.yml
@@ -8,18 +8,15 @@ on:
- 'release/*'
jobs:
- publish-release:
- if: github.event.pull_request.merged == true
+ publish-to-npm:
+ name: Publish to NPM
runs-on: ubuntu-latest
-
- permissions:
- contents: write
- id-token: write
-
- timeout-minutes: 60
+ if: github.event.pull_request.merged == true
+ timeout-minutes: 10
env:
NPM_CONFIG_PROVENANCE: true
-
+ outputs:
+ release: ${{ steps.set-release.outputs.release }}
steps:
- name: Checkout
uses: actions/checkout@v4.1.1
@@ -51,25 +48,97 @@ jobs:
pnpm publish -r --publish-branch ${{github.event.pull_request.base.ref}} --access public --tag rc --no-git-checks
npm dist-tag rm n8n rc
+ - id: set-release
+ run: echo "release=${{ env.RELEASE }}" >> $GITHUB_OUTPUT
+
+ publish-to-docker-hub:
+ name: Publish to DockerHub
+ needs: [publish-to-npm]
+ runs-on: ubuntu-latest
+ if: github.event.pull_request.merged == true
+ timeout-minutes: 10
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4.1.1
+ with:
+ fetch-depth: 0
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3.0.0
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3.0.0
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3.0.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Login to DockerHub
+ uses: docker/login-action@v3.0.0
+ with:
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_PASSWORD }}
+
+ - name: Build
+ uses: docker/build-push-action@v5.1.0
+ with:
+ context: ./docker/images/n8n
+ build-args: |
+ N8N_VERSION=${{ needs.publish-to-npm.outputs.release }}
+ platforms: linux/amd64,linux/arm64
+ provenance: false
+ push: true
+ tags: |
+ ${{ secrets.DOCKER_USERNAME }}/n8n:${{ needs.publish-to-npm.outputs.release }}
+ ghcr.io/${{ github.repository_owner }}/n8n:${{ needs.publish-to-npm.outputs.release }}
+
+ create-github-release:
+ name: Create a GitHub Release
+ needs: [publish-to-npm, publish-to-docker-hub]
+ runs-on: ubuntu-latest
+ if: github.event.pull_request.merged == true
+ timeout-minutes: 5
+
+ permissions:
+ contents: write
+ id-token: write
+
+ steps:
- name: Create a Release on GitHub
uses: ncipollo/release-action@v1
with:
commit: ${{github.event.pull_request.base.ref}}
- tag: 'n8n@${{env.RELEASE}}'
+ tag: 'n8n@${{ needs.publish-to-npm.outputs.release }}'
prerelease: true
makeLatest: false
body: ${{github.event.pull_request.body}}
+ trigger-release-note:
+ name: Trigger a release note
+ needs: [publish-to-npm, create-github-release]
+ if: github.event.pull_request.merged == true
+ runs-on: ubuntu-latest
+ steps:
- name: Trigger a release note
- continue-on-error: true
- run: curl -u docsWorkflows:${{ secrets.N8N_WEBHOOK_DOCS_PASSWORD }} --request GET 'https://internal.users.n8n.cloud/webhook/trigger-release-note' --header 'Content-Type:application/json' --data '{"version":"${{env.RELEASE}}"}'
+ run: curl -u docsWorkflows:${{ secrets.N8N_WEBHOOK_DOCS_PASSWORD }} --request GET 'https://internal.users.n8n.cloud/webhook/trigger-release-note' --header 'Content-Type:application/json' --data '{"version":"${{ needs.publish-to-npm.outputs.release }}"}'
- # - name: Merge Release into 'master'
- # run: |
- # git fetch origin
- # git checkout --track origin/master
- # git config user.name "Jan Oberhauser"
- # git config user.email jan.oberhauser@gmail.com
- # git merge --ff n8n@${{env.RELEASE}}
- # git push origin master
- # git push origin :${{github.event.pull_request.base.ref}}
+ merge-back-into-master:
+ name: Merge back into master
+ needs: [publish-to-npm, create-github-release]
+ if: ${{ github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'release:patch') }}
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4.1.1
+ with:
+ fetch-depth: 0
+ - run: |
+ git checkout --track origin/master
+ git config user.name "github-actions[bot]"
+ git config user.email 41898282+github-actions[bot]@users.noreply.github.com
+ git merge --ff n8n@${{ needs.publish-to-npm.outputs.release }}
+ git push origin master
+ git push origin :${{github.event.pull_request.base.ref}}
diff --git a/.github/workflows/test-workflows.yml b/.github/workflows/test-workflows.yml
index 8ba22b590c..2bb91dd065 100644
--- a/.github/workflows/test-workflows.yml
+++ b/.github/workflows/test-workflows.yml
@@ -73,6 +73,7 @@ jobs:
env:
N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}}
SKIP_STATISTICS_EVENTS: true
+ DB_SQLITE_POOL_SIZE: 4
# -
# name: Export credentials
# if: always()
diff --git a/.github/workflows/units-tests-reusable.yml b/.github/workflows/units-tests-reusable.yml
index fe270b1de0..60bf593e82 100644
--- a/.github/workflows/units-tests-reusable.yml
+++ b/.github/workflows/units-tests-reusable.yml
@@ -36,7 +36,6 @@ jobs:
steps:
- uses: actions/checkout@v4.1.1
with:
- repository: n8n-io/n8n
ref: ${{ inputs.ref }}
- run: corepack enable
@@ -50,6 +49,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Setup build cache
+ if: inputs.collectCoverage != true
uses: rharkor/caching-for-turbo@v1.5
- name: Build
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cc2a07dda0..08aadc87da 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,169 @@
+# [1.56.0](https://github.com/n8n-io/n8n/compare/n8n@1.55.0...n8n@1.56.0) (2024-08-21)
+
+
+### Bug Fixes
+
+* Better errors in Switch, If and Filter nodes ([#10457](https://github.com/n8n-io/n8n/issues/10457)) ([aea82cb](https://github.com/n8n-io/n8n/commit/aea82cb74421d516919742127daf669808b57604))
+* **Calendly Trigger Node:** Fix issue with webhook url matching ([#10378](https://github.com/n8n-io/n8n/issues/10378)) ([09c3a8b](https://github.com/n8n-io/n8n/commit/09c3a8b36733a9634ef5948922d6aa7a19bbb592))
+* **core:** Fix payload property in `workflow-post-execute` event ([#10413](https://github.com/n8n-io/n8n/issues/10413)) ([d98e29e](https://github.com/n8n-io/n8n/commit/d98e29e3d53de87aec276260615fa60473a2692f))
+* **core:** Fix XSS validation and separate URL validation ([#10424](https://github.com/n8n-io/n8n/issues/10424)) ([91467ab](https://github.com/n8n-io/n8n/commit/91467ab325e4c71c20c522f3143246d270101626))
+* **core:** Replace `sanitize-html` with `xss` in XSS validator constraint ([#10479](https://github.com/n8n-io/n8n/issues/10479)) ([5dea51a](https://github.com/n8n-io/n8n/commit/5dea51aad7d9e7ffc676d16f4bbbdecce5876f0b))
+* **core:** Use class-validator with XSS check for survey answers ([#10490](https://github.com/n8n-io/n8n/issues/10490)) ([547a606](https://github.com/n8n-io/n8n/commit/547a60642ce9e54819d4e600c822d87dabd59b2e))
+* **core:** Use explicit types in configs to ensure valid decorator metadata ([#10433](https://github.com/n8n-io/n8n/issues/10433)) ([2043daa](https://github.com/n8n-io/n8n/commit/2043daa2570bc04b0b8d41f277901a8cc8a7b98f))
+* **editor:** Add workflow scopes when initializing workflow ([#10455](https://github.com/n8n-io/n8n/issues/10455)) ([b857c2c](https://github.com/n8n-io/n8n/commit/b857c2cda0a9e4386a540d5e1e741570d9453588))
+* **editor:** Buffer json chunks in stream response ([#10439](https://github.com/n8n-io/n8n/issues/10439)) ([37797f3](https://github.com/n8n-io/n8n/commit/37797f38d81b12d030ba85034baeb49192ea575c))
+* **editor:** Fix flaky mapping tests ([#10453](https://github.com/n8n-io/n8n/issues/10453)) ([fc6d413](https://github.com/n8n-io/n8n/commit/fc6d4138d58282f676b32f1a6011b1b6d0184bf2))
+* **editor:** Fix overflow in AI Assistant chat messages ([#10491](https://github.com/n8n-io/n8n/issues/10491)) ([4a6ca63](https://github.com/n8n-io/n8n/commit/4a6ca632100731f85875c639f2164bf1ef415009))
+* **editor:** Highlight matching type in filter component ([#10425](https://github.com/n8n-io/n8n/issues/10425)) ([6bca879](https://github.com/n8n-io/n8n/commit/6bca879d4ae30c7f9a35e8d6672de42cf93be727))
+* **editor:** Show item count in output panel schema view ([#10426](https://github.com/n8n-io/n8n/issues/10426)) ([4dee7cc](https://github.com/n8n-io/n8n/commit/4dee7cc36e5f7768d0b71095b194bf357c92e941))
+* **editor:** Truncate long data pill labels in schema view ([#10427](https://github.com/n8n-io/n8n/issues/10427)) ([1bf2f4f](https://github.com/n8n-io/n8n/commit/1bf2f4f6171d666391bb3a3a312468bc083446e3))
+* Filter component - improve errors ([#10456](https://github.com/n8n-io/n8n/issues/10456)) ([61ac0c7](https://github.com/n8n-io/n8n/commit/61ac0c77755210f570b887951fe6bbec1a323811))
+* **Google Sheets Node:** Better error when column to match on is empty ([#10442](https://github.com/n8n-io/n8n/issues/10442)) ([ce46bf5](https://github.com/n8n-io/n8n/commit/ce46bf516a86d9779f37dd75b0c680d26d88e15d))
+* **Google Sheets Node:** Update name and hint for useAppend option ([#10443](https://github.com/n8n-io/n8n/issues/10443)) ([c5a0c04](https://github.com/n8n-io/n8n/commit/c5a0c049eaf44419c690d151de42fb0c10bd406e))
+* **Google Sheets Node:** Update to returnAllMatches option ([#10440](https://github.com/n8n-io/n8n/issues/10440)) ([f7fb02e](https://github.com/n8n-io/n8n/commit/f7fb02e92a756781f8e35bbbfc25d71c12cb70af))
+* **Invoice Ninja Node:** Fix payment types ([#10462](https://github.com/n8n-io/n8n/issues/10462)) ([129245d](https://github.com/n8n-io/n8n/commit/129245da10be1d645f61e929e40b128bd7813f17))
+* **n8n Form Trigger Node:** Show basic authentication modal on wrong credentials ([#10423](https://github.com/n8n-io/n8n/issues/10423)) ([0dc3e99](https://github.com/n8n-io/n8n/commit/0dc3e99b26bec45e747d83f383cfe5169d89e6b7))
+* **OpenAI Node:** Throw node operations error in case of openAi client error ([#10448](https://github.com/n8n-io/n8n/issues/10448)) ([0d3ed46](https://github.com/n8n-io/n8n/commit/0d3ed461996bbad06015c455f133baab6506437f))
+* Project Viewer always seeing a connection error when testing credentials ([#10417](https://github.com/n8n-io/n8n/issues/10417)) ([613cdd2](https://github.com/n8n-io/n8n/commit/613cdd2ba2c0f224c4857a5fc3eea36dbd683049))
+* Remove unimplemented Postgres credentials options ([#10461](https://github.com/n8n-io/n8n/issues/10461)) ([17ac784](https://github.com/n8n-io/n8n/commit/17ac7844f29d819b91dfaf90b9fe386d98060c42))
+* Rename Assistant back ([#10481](https://github.com/n8n-io/n8n/issues/10481)) ([c410aed](https://github.com/n8n-io/n8n/commit/c410aed4c22182943dc80ede63acda00b7898e10))
+* Require mfa code to change email ([#10354](https://github.com/n8n-io/n8n/issues/10354)) ([39c8e50](https://github.com/n8n-io/n8n/commit/39c8e50ad0513649f5a8cef911b7d6cdd61c2372))
+* **Respond to Webhook Node:** Fix issue preventing the chat trigger from working ([#9886](https://github.com/n8n-io/n8n/issues/9886)) ([9d6ad88](https://github.com/n8n-io/n8n/commit/9d6ad88c14a88fd0dfcb4f9981e38d19cf5f3067))
+* Show input names when node has multiple inputs ([#10434](https://github.com/n8n-io/n8n/issues/10434)) ([973956c](https://github.com/n8n-io/n8n/commit/973956cc26c78c329ff6eb6934d4f0a24060c87c))
+* **Toggl Trigger Node:** Update API version ([#10207](https://github.com/n8n-io/n8n/issues/10207)) ([9bdb1d6](https://github.com/n8n-io/n8n/commit/9bdb1d6dca43fe491c5eb96f093b7eec4509eaff))
+
+
+### Features
+
+* **core:** Support bidirectional communication between specific mains and specific workers ([#10377](https://github.com/n8n-io/n8n/issues/10377)) ([d0fc9de](https://github.com/n8n-io/n8n/commit/d0fc9dee0e17211c1ed130b19286e9573c9ebfbd))
+* **Facebook Graph API Node:** Update node to support API v18 - v20 ([#10419](https://github.com/n8n-io/n8n/issues/10419)) ([e7ee10f](https://github.com/n8n-io/n8n/commit/e7ee10f243663d899d32e61bc6264b4df444e2af))
+
+
+
+# [1.55.0](https://github.com/n8n-io/n8n/compare/n8n@1.54.0...n8n@1.55.0) (2024-08-14)
+
+
+### Bug Fixes
+
+* Add better error handling for chat errors ([#10408](https://github.com/n8n-io/n8n/issues/10408)) ([f82b6e4](https://github.com/n8n-io/n8n/commit/f82b6e4ba9bf527b3a4c17872162d9ae124ead0d))
+* **AI Agent Node:** Fix issues with some tools not populating ([#10406](https://github.com/n8n-io/n8n/issues/10406)) ([51a1edd](https://github.com/n8n-io/n8n/commit/51a1eddbf00393f3881c340cf37cfcca59566c99))
+* **core:** Account for cancelling an execution with no workers available ([#10343](https://github.com/n8n-io/n8n/issues/10343)) ([b044e78](https://github.com/n8n-io/n8n/commit/b044e783e73a499dbd7532a5d489a782d3d021da))
+* **core:** Account for owner when filtering by project ID in `GET /workflows` in Public API ([#10379](https://github.com/n8n-io/n8n/issues/10379)) ([5ac65b3](https://github.com/n8n-io/n8n/commit/5ac65b36bcb1351c6233b951f064f60862f790a5))
+* **core:** Enforce shutdown timer and sequence on `SIGINT` for main ([#10346](https://github.com/n8n-io/n8n/issues/10346)) ([5255793](https://github.com/n8n-io/n8n/commit/5255793afee5653d8356b8e4d2e1009d5cf36164))
+* **core:** Filter out prototype and constructor lookups in expressions ([#10382](https://github.com/n8n-io/n8n/issues/10382)) ([8e7d29a](https://github.com/n8n-io/n8n/commit/8e7d29ad3c4872b1cc147dfcfe9a864ba916692f))
+* **core:** Fix duplicate Redis publisher ([#10392](https://github.com/n8n-io/n8n/issues/10392)) ([45813de](https://github.com/n8n-io/n8n/commit/45813debc963096f63cc0aabe82d9d9f853a39d7))
+* **core:** Fix worker shutdown errors when active executions ([#10353](https://github.com/n8n-io/n8n/issues/10353)) ([e071b73](https://github.com/n8n-io/n8n/commit/e071b73bab34edd4b3e6aef6497514acc504cdc6))
+* **core:** Prevent XSS in user update endpoints ([#10338](https://github.com/n8n-io/n8n/issues/10338)) ([7898498](https://github.com/n8n-io/n8n/commit/78984986a6b4add89df9743b94c113046f1d5ee8))
+* **core:** Prevent XSS via static cache dir ([#10339](https://github.com/n8n-io/n8n/issues/10339)) ([4f392b5](https://github.com/n8n-io/n8n/commit/4f392b5e3e0ee166e85a2e060b3ec7fcf145229b))
+* **core:** Rate limit MFA activation and verification endpoints ([#10330](https://github.com/n8n-io/n8n/issues/10330)) ([b6c47c0](https://github.com/n8n-io/n8n/commit/b6c47c0e3214878d42980d5c9535df52b3984b3c))
+* **editor:** Connect up new project viewer role to the FE ([#9913](https://github.com/n8n-io/n8n/issues/9913)) ([117e2d9](https://github.com/n8n-io/n8n/commit/117e2d968fcc535f32c583ac8f2dc8a84e8cd6bd))
+* **editor:** Enable credential sharing between all types of projects ([#10233](https://github.com/n8n-io/n8n/issues/10233)) ([1cf48cc](https://github.com/n8n-io/n8n/commit/1cf48cc3019c1cf27e2f3c9affd18426237e9064))
+* **editor:** Fix rendering of SVG icons in public chat on iOS ([#10381](https://github.com/n8n-io/n8n/issues/10381)) ([7ab3811](https://github.com/n8n-io/n8n/commit/7ab38114dbf3881afba39287a061446ec4bf0431))
+* **editor:** Fixing XSS vulnerability in toast messages ([#10329](https://github.com/n8n-io/n8n/issues/10329)) ([38bdd9f](https://github.com/n8n-io/n8n/commit/38bdd9f5d0d9ca06fab1a7e1a3e7a4a648a6a89a))
+* **editor:** Revert change that hid swagger docs in the ui ([#10350](https://github.com/n8n-io/n8n/issues/10350)) ([bae49d3](https://github.com/n8n-io/n8n/commit/bae49d3198d4bcc27e7996cd4f7be3132becc98e))
+* **n8n Form Trigger Node:** Fix issue preventing v1 node from working ([#10364](https://github.com/n8n-io/n8n/issues/10364)) ([9b647a9](https://github.com/n8n-io/n8n/commit/9b647a9837434e8b75e3ad754ff5136bb5ac760d))
+* Require mfa code for password change if its enabled ([#10341](https://github.com/n8n-io/n8n/issues/10341)) ([9d7caac](https://github.com/n8n-io/n8n/commit/9d7caacc699f10962783393925a980ec6f1ca975))
+* Require mfa code to disable mfa ([#10345](https://github.com/n8n-io/n8n/issues/10345)) ([3384f52](https://github.com/n8n-io/n8n/commit/3384f52a35b835ba1d8633dc94bab0ad6e7023b3))
+
+
+### Features
+
+* Add Ask assistant behind feature flag ([#9995](https://github.com/n8n-io/n8n/issues/9995)) ([5ed2a77](https://github.com/n8n-io/n8n/commit/5ed2a77740db1f02b27c571f4dfdfa206ebdb19c))
+* **AI Transform Node:** New node ([#10405](https://github.com/n8n-io/n8n/issues/10405)) ([4d222ac](https://github.com/n8n-io/n8n/commit/4d222ac19d943b69fd9f87abe5e5c5f5141eed8d))
+* **AI Transform Node:** New node ([#9990](https://github.com/n8n-io/n8n/issues/9990)) ([0de9d56](https://github.com/n8n-io/n8n/commit/0de9d56619ed1c055407353046b8a9ebe78da527))
+* **core:** Allow overriding npm registry for community packages ([#10325](https://github.com/n8n-io/n8n/issues/10325)) ([33a2703](https://github.com/n8n-io/n8n/commit/33a2703429d9eaa41f72d2e7d2da5be60b6c620f))
+* **editor:** Add schema view to expression modal ([#9976](https://github.com/n8n-io/n8n/issues/9976)) ([71b6c67](https://github.com/n8n-io/n8n/commit/71b6c671797024d7b516352fa9b7ecda101ce3b2))
+* **MySQL Node:** Return decimal types as numbers ([#10313](https://github.com/n8n-io/n8n/issues/10313)) ([f744d7c](https://github.com/n8n-io/n8n/commit/f744d7c100be68669d9a3efd0033dd371a3cfaf7))
+* **Okta Node:** Add Okta Node ([#10278](https://github.com/n8n-io/n8n/issues/10278)) ([5cac0f3](https://github.com/n8n-io/n8n/commit/5cac0f339d649cfe5857d33738210cbc1599370b))
+
+
+
+# [1.54.0](https://github.com/n8n-io/n8n/compare/n8n@1.53.0...n8n@1.54.0) (2024-08-07)
+
+
+### Bug Fixes
+
+* **core:** Ensure OAuth token data is not stubbed in source control ([#10302](https://github.com/n8n-io/n8n/issues/10302)) ([98115e9](https://github.com/n8n-io/n8n/commit/98115e95df8289a8ec400a570a7f256382f8e286))
+* **core:** Fix expressions in webhook nodes(Form, Webhook) to access previous node's data ([#10247](https://github.com/n8n-io/n8n/issues/10247)) ([88a1701](https://github.com/n8n-io/n8n/commit/88a170176a3447e7f847e9cf145aeb867b1c5fcf))
+* **core:** Fix user telemetry bugs ([#10293](https://github.com/n8n-io/n8n/issues/10293)) ([42a0b59](https://github.com/n8n-io/n8n/commit/42a0b594d6ea2527c55a2aa9976c904cf70ecf92))
+* **core:** Make execution and its data creation atomic ([#10276](https://github.com/n8n-io/n8n/issues/10276)) ([ae50bb9](https://github.com/n8n-io/n8n/commit/ae50bb95a8e5bf1cdbf9483da54b84094b82e260))
+* **core:** Make OAuth1/OAuth2 callback not require auth ([#10263](https://github.com/n8n-io/n8n/issues/10263)) ([a8e2774](https://github.com/n8n-io/n8n/commit/a8e2774f5382e202556b5506c7788265786aa973))
+* **core:** Revert transactions until we remove the legacy sqlite driver ([#10299](https://github.com/n8n-io/n8n/issues/10299)) ([1eba7c3](https://github.com/n8n-io/n8n/commit/1eba7c3c763ac5b6b28c1c6fc43fc8c215249292))
+* **core:** Surface enterprise trial error message ([#10267](https://github.com/n8n-io/n8n/issues/10267)) ([432ac1d](https://github.com/n8n-io/n8n/commit/432ac1da59e173ce4c0f2abbc416743d9953ba70))
+* **core:** Upgrade tournament to address some XSS vulnerabilities ([#10277](https://github.com/n8n-io/n8n/issues/10277)) ([43ae159](https://github.com/n8n-io/n8n/commit/43ae159ea40c574f8e41bdfd221ab2bf3268eee7))
+* **core:** VM2 sandbox should not throw on `new Promise` ([#10298](https://github.com/n8n-io/n8n/issues/10298)) ([7e95f9e](https://github.com/n8n-io/n8n/commit/7e95f9e2e40a99871f1b6abcdacb39ac5f857332))
+* **core:** Webhook and form baseUrl missing ([#10290](https://github.com/n8n-io/n8n/issues/10290)) ([8131d66](https://github.com/n8n-io/n8n/commit/8131d66f8ca1b1da00597a12859ee4372148a0c9))
+* **editor:** Enable moving resources only if team projects are available by the license ([#10271](https://github.com/n8n-io/n8n/issues/10271)) ([42ba884](https://github.com/n8n-io/n8n/commit/42ba8841c401126c77158a53dc8fcbb45dfce8fd))
+* **editor:** Fix execution retry button ([#10275](https://github.com/n8n-io/n8n/issues/10275)) ([55f2ffe](https://github.com/n8n-io/n8n/commit/55f2ffe256c91a028cee95c3bbb37a093a1c0f81))
+* **editor:** Update design system Avatar component to show initials also when only firstName or lastName is given ([#10308](https://github.com/n8n-io/n8n/issues/10308)) ([46bbf09](https://github.com/n8n-io/n8n/commit/46bbf09beacad12472d91786b91d845fe2afb26d))
+* **editor:** Update tags filter/editor to not show non existing tag as a selectable option ([#10297](https://github.com/n8n-io/n8n/issues/10297)) ([557a76e](https://github.com/n8n-io/n8n/commit/557a76ec2326de72fb7a8b46fc4353f8fd9b591d))
+* **Invoice Ninja Node:** Fix payment types ([#10196](https://github.com/n8n-io/n8n/issues/10196)) ([c5acbb7](https://github.com/n8n-io/n8n/commit/c5acbb7ec0d24ec9b30c221fa3b2fb615fb9ec7f))
+* Loop node no input data shown ([#10224](https://github.com/n8n-io/n8n/issues/10224)) ([c8ee852](https://github.com/n8n-io/n8n/commit/c8ee852159207be0cfe2c3e0ee8e7b29d838aa35))
+
+
+### Features
+
+* **core:** Allow filtering executions and users by project in Public API ([#10250](https://github.com/n8n-io/n8n/issues/10250)) ([7056e50](https://github.com/n8n-io/n8n/commit/7056e50b006bda665f64ce6234c5c1967891c415))
+* **core:** Allow transferring credentials in Public API ([#10259](https://github.com/n8n-io/n8n/issues/10259)) ([07d7b24](https://github.com/n8n-io/n8n/commit/07d7b247f02a9d7185beca7817deb779a3d665dd))
+* **core:** Show sub-node error on the logs pane. Open logs pane on sub-node error ([#10248](https://github.com/n8n-io/n8n/issues/10248)) ([57d1c9a](https://github.com/n8n-io/n8n/commit/57d1c9a99e97308f2f1b8ae05ac3861a835e8e5a))
+* **core:** Support community packages in scaling-mode ([#10228](https://github.com/n8n-io/n8n/issues/10228)) ([88086a4](https://github.com/n8n-io/n8n/commit/88086a41ff5b804b35aa9d9503dc2d48836fe4ec))
+* **core:** Support create, delete, edit role for users in Public API ([#10279](https://github.com/n8n-io/n8n/issues/10279)) ([84efbd9](https://github.com/n8n-io/n8n/commit/84efbd9b9c51f536b21a4f969ab607d277bef692))
+* **core:** Support create, read, update, delete projects in Public API ([#10269](https://github.com/n8n-io/n8n/issues/10269)) ([489ce10](https://github.com/n8n-io/n8n/commit/489ce100634c3af678fb300e9a39d273042542e6))
+* **editor:** Auto-add LLM chain for new LLM nodes on empty canvas ([#10245](https://github.com/n8n-io/n8n/issues/10245)) ([06419d9](https://github.com/n8n-io/n8n/commit/06419d9483ae916e79aace6d8c17e265b419b15d))
+* **Elasticsearch Node:** Add bulk operations for Elasticsearch ([#9940](https://github.com/n8n-io/n8n/issues/9940)) ([bf8f848](https://github.com/n8n-io/n8n/commit/bf8f848645dfd31527713a55bd1fc93865327017))
+* **Lemlist Trigger Node:** Update Trigger events ([#10311](https://github.com/n8n-io/n8n/issues/10311)) ([15f10ec](https://github.com/n8n-io/n8n/commit/15f10ec325cb5eda0f952bed3a5f171dd91bc639))
+* **MongoDB Node:** Add projection to query options on Find ([#9972](https://github.com/n8n-io/n8n/issues/9972)) ([0a84e0d](https://github.com/n8n-io/n8n/commit/0a84e0d8b047669f5cf023c21383d01c929c5b4f))
+* **Postgres Chat Memory, Redis Chat Memory, Xata:** Add support for context window length ([#10203](https://github.com/n8n-io/n8n/issues/10203)) ([e3edeaa](https://github.com/n8n-io/n8n/commit/e3edeaa03526f041d15d1099ea91869e38a0decc))
+* **Stripe Trigger Node:** Add Stripe webhook descriptions based on the workflow ID and name ([#9956](https://github.com/n8n-io/n8n/issues/9956)) ([3433465](https://github.com/n8n-io/n8n/commit/34334651e0e6874736a437a894176bed4590e5a7))
+* **Webflow Node:** Update to use the v2 API ([#9996](https://github.com/n8n-io/n8n/issues/9996)) ([6d8323f](https://github.com/n8n-io/n8n/commit/6d8323fadea8af04483eb1a873df0cf3ccc2a891))
+
+
+
+# [1.53.0](https://github.com/n8n-io/n8n/compare/n8n@1.52.0...n8n@1.53.0) (2024-07-31)
+
+
+### Bug Fixes
+
+* Better error message when calling data transformation functions on a null value ([#10210](https://github.com/n8n-io/n8n/issues/10210)) ([1718125](https://github.com/n8n-io/n8n/commit/1718125c6d8589cf24dc8d34f6808dd6f1802691))
+* **core:** Fix missing successful items on continueErrorOutput with multiple outputs ([#10218](https://github.com/n8n-io/n8n/issues/10218)) ([1a7713e](https://github.com/n8n-io/n8n/commit/1a7713ef263680da43f08b6c8a15aee7a0341493))
+* **core:** Flush instance stopped event immediately ([#10238](https://github.com/n8n-io/n8n/issues/10238)) ([d6770b5](https://github.com/n8n-io/n8n/commit/d6770b5fcaec6438d677b918aaeb1669ad7424c2))
+* **core:** Restore log event `n8n.workflow.failed` ([#10253](https://github.com/n8n-io/n8n/issues/10253)) ([3e96b29](https://github.com/n8n-io/n8n/commit/3e96b293329525c9d4b2fcef87b3803e458c8e7f))
+* **core:** Upgrade @n8n/vm2 to address CVE‑2023‑37466 ([#10265](https://github.com/n8n-io/n8n/issues/10265)) ([2a09a03](https://github.com/n8n-io/n8n/commit/2a09a036d2e916acff7ee50904f1d011a93758e1))
+* **editor:** Defer `User saved credentials` telemetry event for OAuth credentials ([#10215](https://github.com/n8n-io/n8n/issues/10215)) ([40a5226](https://github.com/n8n-io/n8n/commit/40a5226e24448a4428143e69d80ebc78238365a1))
+* **editor:** Fix custom API call notice ([#10227](https://github.com/n8n-io/n8n/issues/10227)) ([5b47c8b](https://github.com/n8n-io/n8n/commit/5b47c8b57b25528cd2d6f97bc6d98707d47f35bc))
+* **editor:** Fix issue with existing credential not opening in HTTP agent tool ([#10167](https://github.com/n8n-io/n8n/issues/10167)) ([906b4c3](https://github.com/n8n-io/n8n/commit/906b4c3c7b2919111cf23eaa12b3c4d507969179))
+* **editor:** Fix parameter input glitch when there was an error loading remote options ([#10209](https://github.com/n8n-io/n8n/issues/10209)) ([c0e3743](https://github.com/n8n-io/n8n/commit/c0e37439a87105a0e66c8ebced42c06dab30dc5e))
+* **editor:** Fix workflow execution list scrolling after filter change ([#10226](https://github.com/n8n-io/n8n/issues/10226)) ([7e64358](https://github.com/n8n-io/n8n/commit/7e643589c67adc0218216ec4b89a95f0edfedbee))
+* **Google BigQuery Node:** Send timeoutMs in query, pagination support ([#10205](https://github.com/n8n-io/n8n/issues/10205)) ([f5722e8](https://github.com/n8n-io/n8n/commit/f5722e8823ccd2bc2b5f43ba3c849797d5690a93))
+* **Google Sheets Node:** Add column names row if sheet is empty ([#10200](https://github.com/n8n-io/n8n/issues/10200)) ([82eba9f](https://github.com/n8n-io/n8n/commit/82eba9fc5ff49b8e2a9db93c10b253fb67a8c644))
+* **Google Sheets Node:** Do not insert row_number as a new column, do not checkForSchemaChanges in update operation ([#10201](https://github.com/n8n-io/n8n/issues/10201)) ([5136d10](https://github.com/n8n-io/n8n/commit/5136d10ca3492f92af67d4a1d4abc774419580cc))
+* **Google Sheets Node:** Fix Google Sheet URL regex ([#10195](https://github.com/n8n-io/n8n/issues/10195)) ([e6fd996](https://github.com/n8n-io/n8n/commit/e6fd996973d4f40facf0ebf1eea3cc26acd0603d))
+* **HTTP Request Node:** Resolve max pages expression ([#10192](https://github.com/n8n-io/n8n/issues/10192)) ([bfc8e1b](https://github.com/n8n-io/n8n/commit/bfc8e1b56f7714e1f52aae747d58d686b86e60f0))
+* **LinkedIn Node:** Fix issue with some characters cutting off posts early ([#10185](https://github.com/n8n-io/n8n/issues/10185)) ([361b5e7](https://github.com/n8n-io/n8n/commit/361b5e7c37ba49b68dcf5b8122621aad4d8d96e0))
+* **Postgres Node:** Expressions in query parameters for Postgres executeQuery operation ([#10217](https://github.com/n8n-io/n8n/issues/10217)) ([519fc4d](https://github.com/n8n-io/n8n/commit/519fc4d75325a80b84cc4dcacf52d6f4c02e3a44))
+* **Postgres Node:** Option to treat query parameters enclosed in single quotas as text ([#10214](https://github.com/n8n-io/n8n/issues/10214)) ([00ec253](https://github.com/n8n-io/n8n/commit/00ec2533374d3def465efee718592fc4001d5602))
+* **Read/Write Files from Disk Node:** Notice update in file selector, replace backslashes with forward slashes if windows path ([#10186](https://github.com/n8n-io/n8n/issues/10186)) ([3eac673](https://github.com/n8n-io/n8n/commit/3eac673b17986c5c74bd2adb5ad589ba0ca55319))
+* **Text Classifier Node:** Use proper documentation URL and respect continueOnFail ([#10216](https://github.com/n8n-io/n8n/issues/10216)) ([452f52c](https://github.com/n8n-io/n8n/commit/452f52c124017e002e86c547ba42b1633b14beed))
+* **Trello Node:** Use body for POST requests ([#10189](https://github.com/n8n-io/n8n/issues/10189)) ([7775d50](https://github.com/n8n-io/n8n/commit/7775d5059b7f69d9af22e7ad7d12c6cf9092a4e5))
+* **Wait Node:** Authentication fix ([#10236](https://github.com/n8n-io/n8n/issues/10236)) ([f87854f](https://github.com/n8n-io/n8n/commit/f87854f8db360b7b870583753fcfb4af95adab8c))
+
+
+### Features
+
+* **Calendly Trigger Node:** Add OAuth Credentials Support ([#10251](https://github.com/n8n-io/n8n/issues/10251)) ([326c983](https://github.com/n8n-io/n8n/commit/326c983915a2c382e32398358e7dcadd022c0b77))
+* **core:** Allow filtering workflows by project and transferring workflows in Public API ([#10231](https://github.com/n8n-io/n8n/issues/10231)) ([d719899](https://github.com/n8n-io/n8n/commit/d719899223907b20a17883a35e4ef637a3453532))
+* **editor:** Show new executions as `Queued` in the UI, until they actually start ([#10204](https://github.com/n8n-io/n8n/issues/10204)) ([44728d7](https://github.com/n8n-io/n8n/commit/44728d72423f5549dda09589f4a618ebd80899cb))
+* **HTTP Request Node:** Add option to disable lowercase headers ([#10154](https://github.com/n8n-io/n8n/issues/10154)) ([5aba69b](https://github.com/n8n-io/n8n/commit/5aba69bcf4d232d9860f3cd9fe57cb8839a2f96f))
+* **Information Extractor Node:** Add new simplified AI-node for information extraction ([#10149](https://github.com/n8n-io/n8n/issues/10149)) ([3d235b0](https://github.com/n8n-io/n8n/commit/3d235b0b2df756df35ac60e3dcd87ad183a07167))
+* Introduce Google Cloud Platform as external secrets provider ([#10146](https://github.com/n8n-io/n8n/issues/10146)) ([3ccb9df](https://github.com/n8n-io/n8n/commit/3ccb9df2f902e46f8cbb9c46c0727f29d752a773))
+* **n8n Form Trigger Node:** Improvements ([#10092](https://github.com/n8n-io/n8n/issues/10092)) ([711b667](https://github.com/n8n-io/n8n/commit/711b667ebefe55740e5eb39f1f0f24ceee10e7b0))
+* Recovery option for jsonParse helper ([#10182](https://github.com/n8n-io/n8n/issues/10182)) ([d165b33](https://github.com/n8n-io/n8n/commit/d165b33ceac4d24d0fc290bffe63b5f551204e38))
+* **Sentiment Analysis Node:** Implement Sentiment Analysis node ([#10184](https://github.com/n8n-io/n8n/issues/10184)) ([8ef0a0c](https://github.com/n8n-io/n8n/commit/8ef0a0c58ac2a84aad649ccbe72aa907d005cc44))
+* **Shopify Node:** Update Shopify API version ([#10155](https://github.com/n8n-io/n8n/issues/10155)) ([e2ee915](https://github.com/n8n-io/n8n/commit/e2ee91569a382bfbf787cf45204c72c821a860a0))
+* Support create, read, delete variables in Public API ([#10241](https://github.com/n8n-io/n8n/issues/10241)) ([af695eb](https://github.com/n8n-io/n8n/commit/af695ebf934526d926ea87fe87df61aa73d70979))
+
+
+
# [1.52.0](https://github.com/n8n-io/n8n/compare/n8n@1.51.0...n8n@1.52.0) (2024-07-24)
diff --git a/README.md b/README.md
index 145ecab8c6..d51ac596ca 100644
--- a/README.md
+++ b/README.md
@@ -95,8 +95,8 @@ development environment ready in minutes.
## License
n8n is [fair-code](https://faircode.io) distributed under the
-[**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md) and the
-[**n8n Enterprise License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE_EE.md).
+[**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/LICENSE.md) and the
+[**n8n Enterprise License**](https://github.com/n8n-io/n8n/blob/master/LICENSE_EE.md).
Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io)
diff --git a/cypress/composables/modals/save-changes-modal.ts b/cypress/composables/modals/save-changes-modal.ts
new file mode 100644
index 0000000000..d44b09bd46
--- /dev/null
+++ b/cypress/composables/modals/save-changes-modal.ts
@@ -0,0 +1,3 @@
+export function getSaveChangesModal() {
+ return cy.get('.el-overlay').contains('Save changes before leaving?');
+}
diff --git a/cypress/composables/projects.ts b/cypress/composables/projects.ts
index 6fa2c6f502..84379088d1 100644
--- a/cypress/composables/projects.ts
+++ b/cypress/composables/projects.ts
@@ -1,41 +1,45 @@
import { CredentialsModal, WorkflowPage } from '../pages';
+import { getVisibleSelect } from '../utils';
const workflowPage = new WorkflowPage();
const credentialsModal = new CredentialsModal();
export const getHomeButton = () => cy.getByTestId('project-home-menu-item');
export const getMenuItems = () => cy.getByTestId('project-menu-item');
-export const getAddProjectButton = () => cy.getByTestId('add-project-menu-item');
+export const getAddProjectButton = () =>
+ cy.getByTestId('add-project-menu-item').should('contain', 'Add project').should('be.visible');
export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a');
export const getProjectTabWorkflows = () => getProjectTabs().filter('a[href$="/workflows"]');
export const getProjectTabCredentials = () => getProjectTabs().filter('a[href$="/credentials"]');
export const getProjectTabSettings = () => getProjectTabs().filter('a[href$="/settings"]');
-export const getProjectSettingsNameInput = () => cy.getByTestId('project-settings-name-input');
+export const getProjectSettingsNameInput = () =>
+ cy.getByTestId('project-settings-name-input').find('input');
export const getProjectSettingsSaveButton = () => cy.getByTestId('project-settings-save-button');
export const getProjectSettingsCancelButton = () =>
cy.getByTestId('project-settings-cancel-button');
export const getProjectSettingsDeleteButton = () =>
cy.getByTestId('project-settings-delete-button');
export const getProjectMembersSelect = () => cy.getByTestId('project-members-select');
-export const addProjectMember = (email: string) => {
+export const addProjectMember = (email: string, role?: string) => {
getProjectMembersSelect().click();
getProjectMembersSelect().get('.el-select-dropdown__item').contains(email.toLowerCase()).click();
+
+ if (role) {
+ cy.getByTestId(`user-list-item-${email}`)
+ .find('[data-test-id="projects-settings-user-role-select"]')
+ .click();
+ getVisibleSelect().find('li').contains(role).click();
+ }
};
-export const getProjectNameInput = () => cy.get('#projectName').find('input');
export const getResourceMoveModal = () => cy.getByTestId('project-move-resource-modal');
export const getResourceMoveConfirmModal = () =>
cy.getByTestId('project-move-resource-confirm-modal');
export const getProjectMoveSelect = () => cy.getByTestId('project-move-resource-modal-select');
export function createProject(name: string) {
- getAddProjectButton().should('be.visible').click();
+ getAddProjectButton().click();
- getProjectNameInput()
- .should('be.visible')
- .should('be.focused')
- .should('have.value', 'My project')
- .clear()
- .type(name);
+ getProjectSettingsNameInput().should('be.visible').clear().type(name);
getProjectSettingsSaveButton().click();
}
@@ -46,7 +50,7 @@ export function createWorkflow(fixtureKey: string, name: string) {
workflowPage.actions.zoomToFit();
}
-export function createCredential(name: string) {
+export function createCredential(name: string, closeModal = true) {
credentialsModal.getters.newCredentialModal().should('be.visible');
credentialsModal.getters.newCredentialTypeSelect().should('be.visible');
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
@@ -54,13 +58,8 @@ export function createCredential(name: string) {
credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890');
credentialsModal.actions.setName(name);
credentialsModal.actions.save();
- credentialsModal.actions.close();
-}
-export const actions = {
- createProject: (name: string) => {
- getAddProjectButton().click();
- getProjectSettingsNameInput().type(name);
- getProjectSettingsSaveButton().click();
- },
-};
+ if (closeModal) {
+ credentialsModal.actions.close();
+ }
+}
diff --git a/cypress/constants.ts b/cypress/constants.ts
index 8439952ac7..6f7e7b978d 100644
--- a/cypress/constants.ts
+++ b/cypress/constants.ts
@@ -37,6 +37,7 @@ export const INSTANCE_MEMBERS = [
export const MANUAL_TRIGGER_NODE_NAME = 'Manual Trigger';
export const MANUAL_TRIGGER_NODE_DISPLAY_NAME = 'When clicking ‘Test workflow’';
export const MANUAL_CHAT_TRIGGER_NODE_NAME = 'Chat Trigger';
+export const CHAT_TRIGGER_NODE_DISPLAY_NAME = 'When chat message received';
export const SCHEDULE_TRIGGER_NODE_NAME = 'Schedule Trigger';
export const CODE_NODE_NAME = 'Code';
export const SET_NODE_NAME = 'Set';
@@ -57,6 +58,7 @@ export const AI_TOOL_CODE_NODE_NAME = 'Code Tool';
export const AI_TOOL_WIKIPEDIA_NODE_NAME = 'Wikipedia';
export const AI_TOOL_HTTP_NODE_NAME = 'HTTP Request Tool';
export const AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME = 'OpenAI Chat Model';
+export const AI_MEMORY_POSTGRES_NODE_NAME = 'Postgres Chat Memory';
export const AI_OUTPUT_PARSER_AUTO_FIXING_NODE_NAME = 'Auto-fixing Output Parser';
export const WEBHOOK_NODE_NAME = 'Webhook';
diff --git a/cypress/e2e/11-inline-expression-editor.cy.ts b/cypress/e2e/11-inline-expression-editor.cy.ts
index 143648ce1b..945c62821b 100644
--- a/cypress/e2e/11-inline-expression-editor.cy.ts
+++ b/cypress/e2e/11-inline-expression-editor.cy.ts
@@ -11,6 +11,21 @@ describe('Inline expression editor', () => {
cy.on('uncaught:exception', (error) => error.name !== 'ExpressionError');
});
+ describe('Basic UI functionality', () => {
+ it('should open and close inline expression preview', () => {
+ WorkflowPage.actions.zoomToFit();
+ WorkflowPage.actions.openNode('Schedule');
+ WorkflowPage.actions.openInlineExpressionEditor();
+ WorkflowPage.getters.inlineExpressionEditorInput().clear();
+ WorkflowPage.getters.inlineExpressionEditorInput().click().type('{{');
+ WorkflowPage.getters.inlineExpressionEditorInput().type('123');
+ WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^123$/);
+ // click outside to close
+ ndv.getters.outputPanel().click();
+ WorkflowPage.getters.inlineExpressionEditorOutput().should('not.exist');
+ });
+ });
+
describe('Static data', () => {
beforeEach(() => {
WorkflowPage.actions.addNodeToCanvas('Hacker News');
diff --git a/cypress/e2e/12-canvas-actions.cy.ts b/cypress/e2e/12-canvas-actions.cy.ts
index 9b05cb84d4..53dad1cc89 100644
--- a/cypress/e2e/12-canvas-actions.cy.ts
+++ b/cypress/e2e/12-canvas-actions.cy.ts
@@ -145,7 +145,16 @@ describe('Canvas Actions', () => {
});
});
- it('should delete connections by pressing the delete button', () => {
+ it('should delete node by pressing keyboard backspace', () => {
+ WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
+ WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
+ WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
+ WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).click();
+ cy.get('body').type('{backspace}');
+ WorkflowPage.getters.nodeConnections().should('have.length', 0);
+ });
+
+ it('should delete connections by clicking on the delete button', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
diff --git a/cypress/e2e/14-mapping.cy.ts b/cypress/e2e/14-mapping.cy.ts
index 2a33aee5c0..43103415e3 100644
--- a/cypress/e2e/14-mapping.cy.ts
+++ b/cypress/e2e/14-mapping.cy.ts
@@ -40,11 +40,13 @@ describe('Data mapping', () => {
ndv.actions.mapDataFromHeader(1, 'value');
ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.timestamp }}');
+ ndv.getters.inlineExpressionEditorInput().type('{esc}');
+ ndv.getters.parameterExpressionPreview('value').should('include.text', '2024');
ndv.actions.mapDataFromHeader(2, 'value');
ndv.getters
.inlineExpressionEditorInput()
- .should('have.text', "{{ $json.timestamp }} {{ $json['Readable date'] }}");
+ .should('have.text', "{{ $json['Readable date'] }}{{ $json.timestamp }}");
});
it('maps expressions from table json, and resolves value based on hover', () => {
@@ -133,6 +135,7 @@ describe('Data mapping', () => {
ndv.actions.mapToParameter('value');
ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.input[0].count }}');
+ ndv.getters.inlineExpressionEditorInput().type('{esc}');
ndv.getters.parameterExpressionPreview('value').should('include.text', '0');
ndv.getters
@@ -145,8 +148,9 @@ describe('Data mapping', () => {
ndv.actions.mapToParameter('value');
ndv.getters
.inlineExpressionEditorInput()
- .should('have.text', '{{ $json.input[0].count }} {{ $json.input }}');
- ndv.actions.validateExpressionPreview('value', '0 [object Object]');
+ .should('have.text', '{{ $json.input }}{{ $json.input[0].count }}');
+ ndv.getters.inlineExpressionEditorInput().type('{esc}');
+ ndv.actions.validateExpressionPreview('value', '[object Object]0');
});
it('maps expressions from schema view', () => {
@@ -163,6 +167,7 @@ describe('Data mapping', () => {
ndv.actions.mapToParameter('value');
ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.input[0].count }}');
+ ndv.getters.inlineExpressionEditorInput().type('{esc}');
ndv.actions.validateExpressionPreview('value', '0');
ndv.getters.inputDataContainer().find('span').contains('input').realMouseDown();
@@ -170,8 +175,8 @@ describe('Data mapping', () => {
ndv.actions.mapToParameter('value');
ndv.getters
.inlineExpressionEditorInput()
- .should('have.text', '{{ $json.input[0].count }} {{ $json.input }}');
- ndv.actions.validateExpressionPreview('value', '0 [object Object]');
+ .should('have.text', '{{ $json.input }}{{ $json.input[0].count }}');
+ ndv.actions.validateExpressionPreview('value', '[object Object]0');
});
it('maps expressions from previous nodes', () => {
@@ -192,6 +197,7 @@ describe('Data mapping', () => {
ndv.getters
.inlineExpressionEditorInput()
.should('have.text', `{{ $('${SCHEDULE_TRIGGER_NODE_NAME}').item.json.input[0].count }}`);
+ ndv.getters.inlineExpressionEditorInput().type('{esc}');
ndv.actions.switchInputMode('Table');
ndv.actions.selectInputNode(SCHEDULE_TRIGGER_NODE_NAME);
@@ -200,17 +206,17 @@ describe('Data mapping', () => {
.inlineExpressionEditorInput()
.should(
'have.text',
- `{{ $('${SCHEDULE_TRIGGER_NODE_NAME}').item.json.input[0].count }} {{ $('${SCHEDULE_TRIGGER_NODE_NAME}').item.json.input }}`,
+ `{{ $('${SCHEDULE_TRIGGER_NODE_NAME}').item.json.input }}{{ $('${SCHEDULE_TRIGGER_NODE_NAME}').item.json.input[0].count }}`,
);
ndv.actions.selectInputNode('Set');
ndv.getters.executingLoader().should('not.exist');
ndv.getters.inputDataContainer().should('exist');
- ndv.actions.validateExpressionPreview('value', '0 [object Object]');
+ ndv.actions.validateExpressionPreview('value', '[object Object]0');
ndv.getters.inputTbodyCell(2, 0).realHover();
- ndv.actions.validateExpressionPreview('value', '1 [object Object]');
+ ndv.actions.validateExpressionPreview('value', '[object Object]1');
});
it('maps keys to path', () => {
@@ -271,12 +277,12 @@ describe('Data mapping', () => {
ndv.actions.typeIntoParameterInput('value', 'fun');
ndv.actions.clearParameterInput('value'); // keep focus on param
- cy.wait(300);
ndv.getters.inputDataContainer().should('exist').find('span').contains('count').realMouseDown();
ndv.actions.mapToParameter('value');
ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.input[0].count }}');
+ ndv.getters.inlineExpressionEditorInput().type('{esc}');
ndv.actions.validateExpressionPreview('value', '0');
ndv.getters.inputDataContainer().find('span').contains('input').realMouseDown();
@@ -284,8 +290,8 @@ describe('Data mapping', () => {
ndv.actions.mapToParameter('value');
ndv.getters
.inlineExpressionEditorInput()
- .should('have.text', '{{ $json.input[0].count }} {{ $json.input }}');
- ndv.actions.validateExpressionPreview('value', '0 [object Object]');
+ .should('have.text', '{{ $json.input }}{{ $json.input[0].count }}');
+ ndv.actions.validateExpressionPreview('value', '[object Object]0');
});
it('renders expression preview when a previous node is selected', () => {
@@ -342,4 +348,31 @@ describe('Data mapping', () => {
.invoke('css', 'border')
.should('include', 'dashed rgb(90, 76, 194)');
});
+
+ it('maps expressions to a specific location in the editor', () => {
+ cy.fixture('Test_workflow_3.json').then((data) => {
+ cy.get('body').paste(JSON.stringify(data));
+ });
+ workflowPage.actions.zoomToFit();
+
+ workflowPage.actions.openNode('Set');
+ ndv.actions.typeIntoParameterInput('value', '=');
+ ndv.getters.inlineExpressionEditorInput().find('.cm-content').paste('hello world\n\nnewline');
+ ndv.getters.inlineExpressionEditorInput().type('{esc}');
+
+ ndv.getters.inputDataContainer().should('exist').find('span').contains('count').realMouseDown();
+ ndv.actions.mapToParameter('value');
+ ndv.getters
+ .inlineExpressionEditorInput()
+ .should('have.text', '{{ $json.input[0].count }}hello worldnewline');
+ ndv.getters.inlineExpressionEditorInput().type('{esc}');
+ ndv.actions.validateExpressionPreview('value', '0hello world\n\nnewline');
+
+ ndv.getters.inputDataContainer().find('span').contains('input').realMouseDown();
+ ndv.actions.mapToParameter('value', 'center');
+
+ ndv.getters
+ .inlineExpressionEditorInput()
+ .should('have.text', '{{ $json.input[0].count }}hello world{{ $json.input }}newline');
+ });
});
diff --git a/cypress/e2e/17-sharing.cy.ts b/cypress/e2e/17-sharing.cy.ts
index 54c5e6efe2..64769ae193 100644
--- a/cypress/e2e/17-sharing.cy.ts
+++ b/cypress/e2e/17-sharing.cy.ts
@@ -7,7 +7,7 @@ import {
WorkflowSharingModal,
WorkflowsPage,
} from '../pages';
-import { getVisibleSelect } from '../utils';
+import { getVisibleDropdown, getVisibleSelect } from '../utils';
import * as projects from '../composables/projects';
/**
@@ -192,11 +192,79 @@ describe('Sharing', { disableAutoLogin: true }, () => {
credentialsModal.actions.saveSharing();
credentialsModal.actions.close();
});
+
+ it('credentials should work between team and personal projects', () => {
+ cy.resetDatabase();
+ cy.enableFeature('sharing');
+ cy.enableFeature('advancedPermissions');
+ cy.enableFeature('projectRole:admin');
+ cy.enableFeature('projectRole:editor');
+ cy.changeQuota('maxTeamProjects', -1);
+
+ cy.signinAsOwner();
+ cy.visit('/');
+
+ projects.createProject('Development');
+
+ projects.getHomeButton().click();
+ workflowsPage.getters.newWorkflowButtonCard().click();
+ projects.createWorkflow('Test_workflow_1.json', 'Test workflow');
+
+ projects.getHomeButton().click();
+ projects.getProjectTabCredentials().click();
+ credentialsPage.getters.emptyListCreateCredentialButton().click();
+ projects.createCredential('Notion API');
+
+ credentialsPage.getters.credentialCard('Notion API').click();
+ credentialsModal.actions.changeTab('Sharing');
+ credentialsModal.getters.usersSelect().click();
+ getVisibleSelect()
+ .find('li')
+ .should('have.length', 4)
+ .filter(':contains("Development")')
+ .should('have.length', 1)
+ .click();
+ credentialsModal.getters.saveButton().click();
+ credentialsModal.actions.close();
+
+ projects.getProjectTabWorkflows().click();
+ workflowsPage.getters.workflowCardActions('Test workflow').click();
+ getVisibleDropdown().find('li').contains('Share').click();
+
+ workflowSharingModal.getters.usersSelect().filter(':visible').click();
+ getVisibleSelect().find('li').should('have.length', 3).first().click();
+ workflowSharingModal.getters.saveButton().click();
+
+ projects.getMenuItems().first().click();
+ workflowsPage.getters.newWorkflowButtonCard().click();
+ projects.createWorkflow('Test_workflow_1.json', 'Test workflow 2');
+ workflowPage.actions.openShareModal();
+ workflowSharingModal.getters.usersSelect().should('not.exist');
+
+ cy.get('body').type('{esc}');
+
+ projects.getMenuItems().first().click();
+ projects.getProjectTabCredentials().click();
+ credentialsPage.getters.createCredentialButton().click();
+ projects.createCredential('Notion API 2', false);
+ credentialsModal.actions.changeTab('Sharing');
+ credentialsModal.getters.usersSelect().click();
+ getVisibleSelect().find('li').should('have.length', 4).first().click();
+ credentialsModal.getters.saveButton().click();
+ credentialsModal.actions.close();
+
+ credentialsPage.getters
+ .credentialCards()
+ .should('have.length', 2)
+ .filter(':contains("Owned by me")')
+ .should('have.length', 1);
+ });
});
describe('Credential Usage in Cross Shared Workflows', () => {
beforeEach(() => {
cy.resetDatabase();
+ cy.enableFeature('sharing');
cy.enableFeature('advancedPermissions');
cy.enableFeature('projectRole:admin');
cy.enableFeature('projectRole:editor');
@@ -207,23 +275,18 @@ describe('Credential Usage in Cross Shared Workflows', () => {
});
it('should only show credentials from the same team project', () => {
- cy.enableFeature('advancedPermissions');
- cy.enableFeature('projectRole:admin');
- cy.enableFeature('projectRole:editor');
- cy.changeQuota('maxTeamProjects', -1);
-
// Create a notion credential in the home project
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
// Create a notion credential in one project
- projects.actions.createProject('Development');
+ projects.createProject('Development');
projects.getProjectTabCredentials().click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
// Create a notion credential in another project
- projects.actions.createProject('Test');
+ projects.createProject('Test');
projects.getProjectTabCredentials().click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.actions.createNewCredential('Notion API');
@@ -238,10 +301,36 @@ describe('Credential Usage in Cross Shared Workflows', () => {
getVisibleSelect().find('li').should('have.length', 2);
});
+ it('should only show credentials in their personal project for members', () => {
+ // Create a notion credential as the owner
+ credentialsPage.getters.emptyListCreateCredentialButton().click();
+ credentialsModal.actions.createNewCredential('Notion API');
+
+ // Create another notion credential as the owner, but share it with member
+ // 0
+ credentialsPage.getters.createCredentialButton().click();
+ credentialsModal.actions.createNewCredential('Notion API', false);
+ credentialsModal.actions.changeTab('Sharing');
+ credentialsModal.actions.addUser(INSTANCE_MEMBERS[0].email);
+ credentialsModal.actions.saveSharing();
+
+ // As the member, create a new notion credential and a workflow
+ cy.signinAsMember();
+ cy.visit(credentialsPage.url);
+ credentialsPage.getters.createCredentialButton().click();
+ credentialsModal.actions.createNewCredential('Notion API');
+ cy.visit(workflowsPage.url);
+ workflowsPage.actions.createWorkflowFromCard();
+ workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
+
+ // Only the own credential the shared one (+ the 'Create new' option)
+ // should be in the dropdown
+ workflowPage.getters.nodeCredentialsSelect().click();
+ getVisibleSelect().find('li').should('have.length', 3);
+ });
+
it('should only show credentials in their personal project for members if the workflow was shared with them', () => {
const workflowName = 'Test workflow';
- cy.enableFeature('sharing');
- cy.reload();
// Create a notion credential as the owner and a workflow that is shared
// with member 0
@@ -272,7 +361,6 @@ describe('Credential Usage in Cross Shared Workflows', () => {
it("should show all credentials from all personal projects the workflow's been shared into for the global owner", () => {
const workflowName = 'Test workflow';
- cy.enableFeature('sharing');
// As member 1, create a new notion credential. This should not show up.
cy.signinAsMember(1);
@@ -317,8 +405,6 @@ describe('Credential Usage in Cross Shared Workflows', () => {
});
it('should show all personal credentials if the global owner owns the workflow', () => {
- cy.enableFeature('sharing');
-
// As member 0, create a new notion credential.
cy.signinAsMember();
cy.visit(credentialsPage.url);
diff --git a/cypress/e2e/17-workflow-tags.cy.ts b/cypress/e2e/17-workflow-tags.cy.ts
index 88a2c9973d..26ea7cbe2c 100644
--- a/cypress/e2e/17-workflow-tags.cy.ts
+++ b/cypress/e2e/17-workflow-tags.cy.ts
@@ -1,4 +1,5 @@
import { WorkflowPage } from '../pages';
+import { getVisibleSelect } from '../utils';
const wf = new WorkflowPage();
@@ -64,10 +65,26 @@ describe('Workflow tags', () => {
it('should detach a tag inline by clicking on dropdown list item', () => {
wf.getters.createTagButton().click();
wf.actions.addTags(TEST_TAGS);
- wf.getters.nthTagPill(1).click();
+ wf.getters.workflowTagsContainer().click();
wf.getters.tagsInDropdown().filter('.selected').first().click();
cy.get('body').click(0, 0);
wf.getters.workflowTags().click();
wf.getters.tagPills().should('have.length', TEST_TAGS.length - 1);
});
+
+ it('should not show non existing tag as a selectable option', () => {
+ const NON_EXISTING_TAG = 'My Test Tag';
+
+ wf.getters.createTagButton().click();
+ wf.actions.addTags(TEST_TAGS);
+ cy.get('body').click(0, 0);
+ wf.getters.workflowTags().click();
+ wf.getters.workflowTagsInput().type(NON_EXISTING_TAG);
+
+ getVisibleSelect()
+ .find('li')
+ .should('have.length', 2)
+ .filter(`:contains("${NON_EXISTING_TAG}")`)
+ .should('not.have.length');
+ });
});
diff --git a/cypress/e2e/1858-PAY-can-use-context-menu.ts b/cypress/e2e/1858-PAY-can-use-context-menu.ts
new file mode 100644
index 0000000000..6727df4166
--- /dev/null
+++ b/cypress/e2e/1858-PAY-can-use-context-menu.ts
@@ -0,0 +1,21 @@
+import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
+
+const WorkflowPage = new WorkflowPageClass();
+
+describe('PAY-1858 context menu', () => {
+ it('can use context menu on saved workflow', () => {
+ WorkflowPage.actions.visit();
+ cy.createFixtureWorkflow('Test_workflow_filter.json', 'test');
+
+ WorkflowPage.getters.canvasNodes().should('have.length', 5);
+ WorkflowPage.actions.deleteNodeFromContextMenu('Then');
+ WorkflowPage.getters.canvasNodes().should('have.length', 4);
+
+ WorkflowPage.actions.hitSaveWorkflow();
+
+ cy.reload();
+ WorkflowPage.getters.canvasNodes().should('have.length', 4);
+ WorkflowPage.actions.deleteNodeFromContextMenu('Code');
+ WorkflowPage.getters.canvasNodes().should('have.length', 3);
+ });
+});
diff --git a/cypress/e2e/19-execution.cy.ts b/cypress/e2e/19-execution.cy.ts
index 804e81d4e6..1f7d5c332e 100644
--- a/cypress/e2e/19-execution.cy.ts
+++ b/cypress/e2e/19-execution.cy.ts
@@ -275,7 +275,6 @@ describe('Execution', () => {
.within(() => cy.get('.fa-check').should('not.exist'));
successToast().should('be.visible');
- clearNotifications();
// Clear execution data
workflowPage.getters.clearExecutionDataButton().should('be.visible');
diff --git a/cypress/e2e/2-credentials.cy.ts b/cypress/e2e/2-credentials.cy.ts
index ffecd51959..9dbe6c6b5d 100644
--- a/cypress/e2e/2-credentials.cy.ts
+++ b/cypress/e2e/2-credentials.cy.ts
@@ -112,13 +112,13 @@ describe('Credentials', () => {
workflowPage.getters.nodeCredentialsSelect().should('have.length', 2);
workflowPage.getters.nodeCredentialsSelect().first().click();
- getVisibleSelect().find('li').last().click();
+ getVisibleSelect().find('li').contains('Create New Credential').click();
// This one should show auth type selector
credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2);
cy.get('body').type('{esc}');
workflowPage.getters.nodeCredentialsSelect().last().click();
- getVisibleSelect().find('li').last().click();
+ getVisibleSelect().find('li').contains('Create New Credential').click();
// This one should not show auth type selector
credentialsModal.getters.credentialsAuthTypeSelector().should('not.exist');
});
diff --git a/cypress/e2e/20-workflow-executions.cy.ts b/cypress/e2e/20-workflow-executions.cy.ts
index 075923e940..59f08c570b 100644
--- a/cypress/e2e/20-workflow-executions.cy.ts
+++ b/cypress/e2e/20-workflow-executions.cy.ts
@@ -183,6 +183,50 @@ describe('Current Workflow Executions', () => {
.invoke('attr', 'title')
.should('eq', newWorkflowName);
});
+
+ it('should load items and auto scroll after filter change', () => {
+ createMockExecutions();
+ createMockExecutions();
+ cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
+
+ executionsTab.actions.switchToExecutionsTab();
+
+ cy.wait(['@getExecutions']);
+
+ executionsTab.getters.executionsList().scrollTo(0, 500).wait(0);
+
+ executionsTab.getters.executionListItems().eq(10).click();
+
+ cy.getByTestId('executions-filter-button').click();
+ cy.getByTestId('executions-filter-status-select').should('be.visible').click();
+ getVisibleSelect().find('li:contains("Error")').click();
+
+ executionsTab.getters.executionListItems().should('have.length', 5);
+ executionsTab.getters.successfulExecutionListItems().should('have.length', 1);
+ executionsTab.getters.failedExecutionListItems().should('have.length', 4);
+
+ cy.getByTestId('executions-filter-button').click();
+ cy.getByTestId('executions-filter-status-select').should('be.visible').click();
+ getVisibleSelect().find('li:contains("Success")').click();
+
+ // check if the list is scrolled
+ executionsTab.getters.executionListItems().eq(10).should('be.visible');
+ executionsTab.getters.executionsList().then(($el) => {
+ const { scrollTop, scrollHeight, clientHeight } = $el[0];
+ expect(scrollTop).to.be.greaterThan(0);
+ expect(scrollTop + clientHeight).to.be.lessThan(scrollHeight);
+
+ // scroll to the bottom
+ $el[0].scrollTo(0, scrollHeight);
+ executionsTab.getters.executionListItems().should('have.length', 18);
+ executionsTab.getters.successfulExecutionListItems().should('have.length', 18);
+ executionsTab.getters.failedExecutionListItems().should('have.length', 0);
+ });
+
+ cy.getByTestId('executions-filter-button').click();
+ cy.getByTestId('executions-filter-reset-button').should('be.visible').click();
+ executionsTab.getters.executionListItems().eq(11).should('be.visible');
+ });
});
const createMockExecutions = () => {
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
new file mode 100644
index 0000000000..4c733df90d
--- /dev/null
+++ b/cypress/e2e/233-AI-switch-to-logs-on-error.cy.ts
@@ -0,0 +1,279 @@
+import type { ExecutionError } from 'n8n-workflow/src';
+import { NDV, WorkflowPage as WorkflowPageClass } from '../pages';
+import {
+ addLanguageModelNodeToParent,
+ addMemoryNodeToParent,
+ addNodeToCanvas,
+ addToolNodeToParent,
+ navigateToNewWorkflowPage,
+ openNode,
+} from '../composables/workflow';
+import {
+ AGENT_NODE_NAME,
+ AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
+ AI_MEMORY_POSTGRES_NODE_NAME,
+ AI_TOOL_CALCULATOR_NODE_NAME,
+ CHAT_TRIGGER_NODE_DISPLAY_NAME,
+ MANUAL_CHAT_TRIGGER_NODE_NAME,
+ 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 { createMockNodeExecutionData, getVisibleSelect, runMockWorkflowExecution } from '../utils';
+
+const ndv = new NDV();
+const WorkflowPage = new WorkflowPageClass();
+
+function createRunDataWithError(inputMessage: string) {
+ return [
+ createMockNodeExecutionData(MANUAL_CHAT_TRIGGER_NODE_NAME, {
+ jsonData: {
+ main: { input: inputMessage },
+ },
+ }),
+ createMockNodeExecutionData(AI_MEMORY_POSTGRES_NODE_NAME, {
+ jsonData: {
+ ai_memory: {
+ json: {
+ action: 'loadMemoryVariables',
+ values: {
+ input: inputMessage,
+ system_message: 'You are a helpful assistant',
+ formatting_instructions:
+ 'IMPORTANT: Always call `format_final_response` to format your final response!',
+ },
+ },
+ },
+ },
+ inputOverride: {
+ ai_memory: [
+ [
+ {
+ json: {
+ action: 'loadMemoryVariables',
+ values: {
+ input: inputMessage,
+ system_message: 'You are a helpful assistant',
+ formatting_instructions:
+ 'IMPORTANT: Always call `format_final_response` to format your final response!',
+ },
+ },
+ },
+ ],
+ ],
+ },
+ error: {
+ message: 'Internal error',
+ timestamp: 1722591723244,
+ name: 'NodeOperationError',
+ description: 'Internal error',
+ context: {},
+ cause: {
+ name: 'error',
+ severity: 'FATAL',
+ code: '3D000',
+ file: 'postinit.c',
+ line: '885',
+ routine: 'InitPostgres',
+ } as unknown as Error,
+ } as ExecutionError,
+ }),
+ createMockNodeExecutionData(AGENT_NODE_NAME, {
+ executionStatus: 'error',
+ error: {
+ level: 'error',
+ tags: {
+ packageName: 'workflow',
+ },
+ context: {},
+ functionality: 'configuration-node',
+ name: 'NodeOperationError',
+ timestamp: 1722591723244,
+ node: {
+ parameters: {
+ notice: '',
+ sessionIdType: 'fromInput',
+ tableName: 'n8n_chat_histories',
+ },
+ id: '6b9141da-0135-4e9d-94d1-2d658cbf48b5',
+ name: 'Postgres Chat Memory',
+ type: '@n8n/n8n-nodes-langchain.memoryPostgresChat',
+ typeVersion: 1,
+ position: [1140, 500],
+ credentials: {
+ postgres: {
+ id: 'RkyZetVpGsSfEAhQ',
+ name: 'Postgres account',
+ },
+ },
+ },
+ messages: ['database "chat11" does not exist'],
+ description: 'Internal error',
+ message: 'Internal error',
+ } as unknown as ExecutionError,
+ metadata: {
+ subRun: [
+ {
+ node: 'Postgres Chat Memory',
+ runIndex: 0,
+ },
+ ],
+ },
+ }),
+ ];
+}
+
+function setupTestWorkflow(chatTrigger: boolean = false) {
+ // Setup test workflow with AI Agent, Postgres Memory Node (source of error), Calculator Tool, and OpenAI Chat Model
+ if (chatTrigger) {
+ addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true);
+ } else {
+ addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME, true);
+ }
+
+ addNodeToCanvas(AGENT_NODE_NAME, true);
+
+ if (!chatTrigger) {
+ // Remove chat trigger
+ WorkflowPage.getters
+ .canvasNodeByName(CHAT_TRIGGER_NODE_DISPLAY_NAME)
+ .find('[data-test-id="delete-node-button"]')
+ .click({ force: true });
+
+ // Set manual trigger to output standard pinned data
+ openNode(MANUAL_TRIGGER_NODE_DISPLAY_NAME);
+ ndv.actions.editPinnedData();
+ ndv.actions.savePinnedData();
+ ndv.actions.close();
+ }
+
+ // Calculator is added just to make OpenAI Chat Model work (tools can not be empty with OpenAI model)
+ addToolNodeToParent(AI_TOOL_CALCULATOR_NODE_NAME, AGENT_NODE_NAME);
+ clickGetBackToCanvas();
+
+ addMemoryNodeToParent(AI_MEMORY_POSTGRES_NODE_NAME, AGENT_NODE_NAME);
+
+ clickCreateNewCredential();
+ setCredentialValues({
+ password: 'testtesttest',
+ });
+
+ ndv.getters.parameterInput('sessionIdType').click();
+ getVisibleSelect().contains('Define below').click();
+ ndv.getters.parameterInput('sessionKey').type('asdasd');
+
+ clickGetBackToCanvas();
+
+ addLanguageModelNodeToParent(
+ AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
+ AGENT_NODE_NAME,
+ true,
+ );
+
+ clickCreateNewCredential();
+ setCredentialValues({
+ apiKey: 'sk_test_123',
+ });
+ clickGetBackToCanvas();
+
+ WorkflowPage.actions.zoomToFit();
+}
+
+function checkMessages(inputMessage: string, outputMessage: string) {
+ const messages = getManualChatMessages();
+ messages.should('have.length', 2);
+ messages.should('contain', inputMessage);
+ messages.should('contain', outputMessage);
+
+ getManualChatModalLogs().should('exist');
+ getManualChatModalLogsEntries()
+ .should('have.length', 1)
+ .should('contain', AI_MEMORY_POSTGRES_NODE_NAME);
+}
+
+describe("AI-233 Make root node's logs pane active in case of an error in sub-nodes", () => {
+ beforeEach(() => {
+ navigateToNewWorkflowPage();
+ });
+
+ it('should open logs tab by default when there was an error', () => {
+ setupTestWorkflow(true);
+
+ openNode(AGENT_NODE_NAME);
+
+ const inputMessage = 'Test the code tool';
+
+ clickExecuteNode();
+ runMockWorkflowExecution({
+ trigger: () => sendManualChatMessage(inputMessage),
+ runData: createRunDataWithError(inputMessage),
+ lastNodeExecuted: AGENT_NODE_NAME,
+ });
+
+ checkMessages(inputMessage, '[ERROR: Internal error]');
+ closeManualChatModal();
+
+ // Open the AI Agent node to see the logs
+ openNode(AGENT_NODE_NAME);
+
+ // Finally check that logs pane is opened by default
+ ndv.getters.outputDataContainer().should('be.visible');
+
+ ndv.getters.aiOutputModeToggle().should('be.visible');
+ ndv.getters
+ .aiOutputModeToggle()
+ .find('[role="radio"]')
+ .should('have.length', 2)
+ .eq(1)
+ .should('have.attr', 'aria-checked', 'true');
+
+ ndv.getters
+ .outputPanel()
+ .findChildByTestId('node-error-message')
+ .should('be.visible')
+ .should('contain', 'Error in sub-node');
+ });
+
+ it('should switch to logs tab on error, when NDV is already opened', () => {
+ setupTestWorkflow(false);
+
+ openNode(AGENT_NODE_NAME);
+
+ const inputMessage = 'Test the code tool';
+
+ runMockWorkflowExecution({
+ trigger: () => clickExecuteNode(),
+ runData: createRunDataWithError(inputMessage),
+ lastNodeExecuted: AGENT_NODE_NAME,
+ });
+
+ // Check that logs pane is opened by default
+ ndv.getters.outputDataContainer().should('be.visible');
+
+ ndv.getters.aiOutputModeToggle().should('be.visible');
+ ndv.getters
+ .aiOutputModeToggle()
+ .find('[role="radio"]')
+ .should('have.length', 2)
+ .eq(1)
+ .should('have.attr', 'aria-checked', 'true');
+
+ ndv.getters
+ .outputPanel()
+ .findChildByTestId('node-error-message')
+ .should('be.visible')
+ .should('contain', 'Error in sub-node');
+ });
+});
diff --git a/cypress/e2e/30-langchain.cy.ts b/cypress/e2e/30-langchain.cy.ts
index c1409a34f3..c6d0f4ab4d 100644
--- a/cypress/e2e/30-langchain.cy.ts
+++ b/cypress/e2e/30-langchain.cy.ts
@@ -10,7 +10,9 @@ import {
disableNode,
getExecuteWorkflowButton,
navigateToNewWorkflowPage,
+ getNodes,
openNode,
+ getConnectionBySourceAndTarget,
} from '../composables/workflow';
import {
clickCreateNewCredential,
@@ -41,6 +43,7 @@ import {
AI_TOOL_WIKIPEDIA_NODE_NAME,
BASIC_LLM_CHAIN_NODE_NAME,
EDIT_FIELDS_SET_NODE_NAME,
+ CHAT_TRIGGER_NODE_DISPLAY_NAME,
} from './../constants';
describe('Langchain Integration', () => {
@@ -331,4 +334,27 @@ describe('Langchain Integration', () => {
closeManualChatModal();
});
+
+ it('should auto-add chat trigger and basic LLM chain when adding LLM node', () => {
+ addNodeToCanvas(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, true);
+
+ getConnectionBySourceAndTarget(
+ CHAT_TRIGGER_NODE_DISPLAY_NAME,
+ BASIC_LLM_CHAIN_NODE_NAME,
+ ).should('exist');
+
+ getConnectionBySourceAndTarget(
+ AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
+ BASIC_LLM_CHAIN_NODE_NAME,
+ ).should('exist');
+ getNodes().should('have.length', 3);
+ });
+
+ it('should not auto-add nodes if AI nodes are already present', () => {
+ addNodeToCanvas(AGENT_NODE_NAME, true);
+
+ addNodeToCanvas(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, true);
+ getConnectionBySourceAndTarget(CHAT_TRIGGER_NODE_DISPLAY_NAME, AGENT_NODE_NAME).should('exist');
+ getNodes().should('have.length', 3);
+ });
});
diff --git a/cypress/e2e/33-settings-personal.cy.ts b/cypress/e2e/33-settings-personal.cy.ts
index 73dc7476b8..e3cc572c9e 100644
--- a/cypress/e2e/33-settings-personal.cy.ts
+++ b/cypress/e2e/33-settings-personal.cy.ts
@@ -35,13 +35,14 @@ describe('Personal Settings', () => {
successToast().find('.el-notification__closeBtn').click();
});
});
+ // eslint-disable-next-line n8n-local-rules/no-skipped-tests
it('not allow malicious values for personal data', () => {
cy.visit('/settings/personal');
INVALID_NAMES.forEach((name) => {
cy.getByTestId('personal-data-form').find('input[name="firstName"]').clear().type(name);
cy.getByTestId('personal-data-form').find('input[name="lastName"]').clear().type(name);
cy.getByTestId('save-settings-button').click();
- errorToast().should('contain', 'Malicious firstName | Malicious lastName');
+ errorToast().should('contain', 'Potentially malicious string | Potentially malicious string');
errorToast().find('.el-notification__closeBtn').click();
});
});
diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts
index 94a6384233..e2bf63df7d 100644
--- a/cypress/e2e/39-projects.cy.ts
+++ b/cypress/e2e/39-projects.cy.ts
@@ -1,9 +1,4 @@
-import {
- INSTANCE_MEMBERS,
- INSTANCE_OWNER,
- MANUAL_TRIGGER_NODE_NAME,
- NOTION_NODE_NAME,
-} from '../constants';
+import { INSTANCE_MEMBERS, MANUAL_TRIGGER_NODE_NAME, NOTION_NODE_NAME } from '../constants';
import {
WorkflowsPage,
WorkflowPage,
@@ -11,9 +6,10 @@ import {
CredentialsPage,
WorkflowExecutionsTab,
NDV,
+ MainSidebar,
} from '../pages';
import * as projects from '../composables/projects';
-import { getVisibleSelect } from '../utils';
+import { getVisibleDropdown, getVisibleModalOverlay, getVisibleSelect } from '../utils';
const workflowsPage = new WorkflowsPage();
const workflowPage = new WorkflowPage();
@@ -21,6 +17,7 @@ const credentialsPage = new CredentialsPage();
const credentialsModal = new CredentialsModal();
const executionsTab = new WorkflowExecutionsTab();
const ndv = new NDV();
+const mainSidebar = new MainSidebar();
describe('Projects', { disableAutoLogin: true }, () => {
before(() => {
@@ -237,10 +234,30 @@ describe('Projects', { disableAutoLogin: true }, () => {
cy.signinAsMember(1);
cy.visit(workflowsPage.url);
- projects.getAddProjectButton().should('not.exist');
+ cy.getByTestId('add-project-menu-item').should('not.exist');
projects.getMenuItems().should('not.exist');
});
+ it('should not show viewer role if not licensed', () => {
+ cy.signinAsOwner();
+ cy.visit(workflowsPage.url);
+
+ projects.getMenuItems().first().click();
+ projects.getProjectTabSettings().click();
+
+ cy.get(
+ `[data-test-id="user-list-item-${INSTANCE_MEMBERS[0].email}"] [data-test-id="projects-settings-user-role-select"]`,
+ ).click();
+
+ cy.get('.el-select-dropdown__item.is-disabled')
+ .should('contain.text', 'Viewer')
+ .get('span:contains("Upgrade")')
+ .filter(':visible')
+ .click();
+
+ getVisibleModalOverlay().should('contain.text', 'Upgrade to unlock additional roles');
+ });
+
describe('when starting from scratch', () => {
beforeEach(() => {
cy.resetDatabase();
@@ -257,7 +274,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
// Create a project and add a credential to it
cy.intercept('POST', '/rest/projects').as('projectCreate');
- projects.getAddProjectButton().should('contain', 'Add project').should('be.visible').click();
+ projects.getAddProjectButton().click();
cy.wait('@projectCreate');
projects.getMenuItems().should('have.length', 1);
projects.getMenuItems().first().click();
@@ -418,7 +435,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
});
it('should move resources between projects', () => {
- cy.signin(INSTANCE_OWNER);
+ cy.signinAsOwner();
cy.visit(workflowsPage.url);
// Create a workflow and a credential in the Home project
@@ -563,5 +580,80 @@ describe('Projects', { disableAutoLogin: true }, () => {
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('have.length', 2);
});
+
+ it('should handle viewer role', () => {
+ cy.enableFeature('projectRole:viewer');
+ cy.signinAsOwner();
+ cy.visit(workflowsPage.url);
+
+ projects.createProject('Development');
+ projects.addProjectMember(INSTANCE_MEMBERS[0].email, 'Viewer');
+ projects.getProjectSettingsSaveButton().click();
+
+ projects.getProjectTabWorkflows().click();
+ workflowsPage.getters.newWorkflowButtonCard().click();
+ projects.createWorkflow('Test_workflow_4_executions_view.json', 'WF with random error');
+ executionsTab.actions.createManualExecutions(2);
+ executionsTab.actions.toggleNodeEnabled('Error');
+ executionsTab.actions.createManualExecutions(2);
+ workflowPage.actions.saveWorkflowUsingKeyboardShortcut();
+
+ projects.getMenuItems().first().click();
+ projects.getProjectTabCredentials().click();
+ credentialsPage.getters.emptyListCreateCredentialButton().click();
+ projects.createCredential('Notion API');
+
+ mainSidebar.actions.openUserMenu();
+ cy.getByTestId('user-menu-item-logout').click();
+
+ cy.get('input[name="email"]').type(INSTANCE_MEMBERS[0].email);
+ cy.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password);
+ cy.getByTestId('form-submit-button').click();
+
+ mainSidebar.getters.executions().click();
+ cy.getByTestId('global-execution-list-item').first().find('td:last button').click();
+ getVisibleDropdown()
+ .find('li')
+ .filter(':contains("Retry")')
+ .should('have.class', 'is-disabled');
+ getVisibleDropdown()
+ .find('li')
+ .filter(':contains("Delete")')
+ .should('have.class', 'is-disabled');
+
+ projects.getMenuItems().first().click();
+ cy.getByTestId('workflow-card-name').should('be.visible').first().click();
+ workflowPage.getters.nodeViewRoot().should('be.visible');
+ workflowPage.getters.executeWorkflowButton().should('not.exist');
+ workflowPage.getters.nodeCreatorPlusButton().should('not.exist');
+ workflowPage.getters.canvasNodes().should('have.length', 3).last().click();
+ cy.get('body').type('{backspace}');
+ workflowPage.getters.canvasNodes().should('have.length', 3).last().rightclick();
+ getVisibleDropdown()
+ .find('li')
+ .should('be.visible')
+ .filter(
+ ':contains("Open"), :contains("Copy"), :contains("Select all"), :contains("Clear selection")',
+ )
+ .should('not.have.class', 'is-disabled');
+ cy.get('body').type('{esc}');
+
+ executionsTab.actions.switchToExecutionsTab();
+ cy.getByTestId('retry-execution-button')
+ .should('be.visible')
+ .find('.is-disabled')
+ .should('exist');
+ cy.get('button:contains("Debug")').should('be.disabled');
+ cy.get('button[title="Retry execution"]').should('be.disabled');
+ cy.get('button[title="Delete this execution"]').should('be.disabled');
+
+ projects.getMenuItems().first().click();
+ projects.getProjectTabCredentials().click();
+ credentialsPage.getters.credentialCards().filter(':contains("Notion")').click();
+ cy.getByTestId('node-credentials-config-container')
+ .should('be.visible')
+ .find('input')
+ .should('not.have.length');
+ });
});
});
diff --git a/cypress/e2e/44-routing.cy.ts b/cypress/e2e/44-routing.cy.ts
new file mode 100644
index 0000000000..67a092235b
--- /dev/null
+++ b/cypress/e2e/44-routing.cy.ts
@@ -0,0 +1,26 @@
+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';
+
+const WorkflowsPage = new WorkflowsPageClass();
+const WorkflowPage = new WorkflowPageClass();
+
+describe('Workflows', () => {
+ beforeEach(() => {
+ cy.visit(WorkflowsPage.url);
+ });
+
+ it('should ask to save unsaved changes before leaving route', () => {
+ WorkflowsPage.getters.newWorkflowButtonCard().should('be.visible');
+ WorkflowsPage.getters.newWorkflowButtonCard().click();
+
+ cy.createFixtureWorkflow('Test_workflow_1.json', 'Empty State Card Workflow');
+
+ WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
+
+ cy.getByTestId('project-home-menu-item').click();
+
+ getSaveChangesModal().should('be.visible');
+ });
+});
diff --git a/cypress/e2e/45-ai-assistant.cy.ts b/cypress/e2e/45-ai-assistant.cy.ts
new file mode 100644
index 0000000000..4431007df0
--- /dev/null
+++ b/cypress/e2e/45-ai-assistant.cy.ts
@@ -0,0 +1,247 @@
+import { NDV, WorkflowPage } from '../pages';
+import { AIAssistant } from '../pages/features/ai-assistant';
+
+const wf = new WorkflowPage();
+const ndv = new NDV();
+const aiAssistant = new AIAssistant();
+
+describe('AI Assistant::disabled', () => {
+ beforeEach(() => {
+ aiAssistant.actions.disableAssistant();
+ wf.actions.visit();
+ });
+
+ it('does not show assistant button if feature is disabled', () => {
+ aiAssistant.getters.askAssistantFloatingButton().should('not.exist');
+ });
+});
+
+describe('AI Assistant::enabled', () => {
+ beforeEach(() => {
+ aiAssistant.actions.enableAssistant();
+ wf.actions.visit();
+ });
+
+ after(() => {
+ aiAssistant.actions.disableAssistant();
+ });
+
+ it('renders placeholder UI', () => {
+ aiAssistant.getters.askAssistantFloatingButton().should('be.visible');
+ aiAssistant.getters.askAssistantFloatingButton().click();
+ aiAssistant.getters.askAssistantChat().should('be.visible');
+ aiAssistant.getters.placeholderMessage().should('be.visible');
+ aiAssistant.getters.chatInputWrapper().should('not.exist');
+ aiAssistant.getters.closeChatButton().should('be.visible');
+ aiAssistant.getters.closeChatButton().click();
+ aiAssistant.getters.askAssistantChat().should('not.exist');
+ });
+
+ it('should resize assistant chat up', () => {
+ aiAssistant.getters.askAssistantFloatingButton().click();
+ aiAssistant.getters.askAssistantSidebarResizer().should('be.visible');
+ aiAssistant.getters.askAssistantChat().then((element) => {
+ const { width, left } = element[0].getBoundingClientRect();
+ cy.drag(aiAssistant.getters.askAssistantSidebarResizer(), [left - 10, 0], {
+ abs: true,
+ clickToFinish: true,
+ });
+ aiAssistant.getters.askAssistantChat().then((newElement) => {
+ const newWidth = newElement[0].getBoundingClientRect().width;
+ expect(newWidth).to.be.greaterThan(width);
+ });
+ });
+ });
+
+ it('should resize assistant chat down', () => {
+ aiAssistant.getters.askAssistantFloatingButton().click();
+ aiAssistant.getters.askAssistantSidebarResizer().should('be.visible');
+ aiAssistant.getters.askAssistantChat().then((element) => {
+ const { width, left } = element[0].getBoundingClientRect();
+ cy.drag(aiAssistant.getters.askAssistantSidebarResizer(), [left + 10, 0], {
+ abs: true,
+ clickToFinish: true,
+ });
+ aiAssistant.getters.askAssistantChat().then((newElement) => {
+ const newWidth = newElement[0].getBoundingClientRect().width;
+ expect(newWidth).to.be.lessThan(width);
+ });
+ });
+ });
+
+ it('should start chat session from node error view', () => {
+ cy.intercept('POST', '/rest/ai-assistant/chat', {
+ statusCode: 200,
+ fixture: 'aiAssistant/simple_message_response.json',
+ }).as('chatRequest');
+ cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
+ wf.actions.openNode('Stop and Error');
+ ndv.getters.nodeExecuteButton().click();
+ aiAssistant.getters.nodeErrorViewAssistantButton().click();
+ cy.wait('@chatRequest');
+ aiAssistant.getters.chatMessagesAll().should('have.length', 1);
+ aiAssistant.getters
+ .chatMessagesAll()
+ .eq(0)
+ .should('contain.text', 'Hey, this is an assistant message');
+ aiAssistant.getters.nodeErrorViewAssistantButton().should('be.disabled');
+ });
+
+ it('should render chat input correctly', () => {
+ cy.intercept('POST', '/rest/ai-assistant/chat', {
+ statusCode: 200,
+ fixture: 'aiAssistant/simple_message_response.json',
+ }).as('chatRequest');
+ cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
+ wf.actions.openNode('Stop and Error');
+ ndv.getters.nodeExecuteButton().click();
+ aiAssistant.getters.nodeErrorViewAssistantButton().click();
+ cy.wait('@chatRequest');
+ // Send button should be disabled when input is empty
+ aiAssistant.getters.sendMessageButton().should('be.disabled');
+ aiAssistant.getters.chatInput().type('Yo ');
+ aiAssistant.getters.sendMessageButton().should('not.be.disabled');
+ aiAssistant.getters.chatInput().then((element) => {
+ const { height } = element[0].getBoundingClientRect();
+ // Shift + Enter should add a new line
+ aiAssistant.getters.chatInput().type('Hello{shift+enter}there');
+ aiAssistant.getters.chatInput().then((newElement) => {
+ const newHeight = newElement[0].getBoundingClientRect().height;
+ // Chat input should grow as user adds new lines
+ expect(newHeight).to.be.greaterThan(height);
+ aiAssistant.getters.sendMessageButton().click();
+ cy.wait('@chatRequest');
+ // New lines should be rendered as
in the chat
+ aiAssistant.getters.chatMessagesUser().should('have.length', 1);
+ aiAssistant.getters.chatMessagesUser().eq(0).find('br').should('have.length', 1);
+ // Chat input should be cleared now
+ aiAssistant.getters.chatInput().should('have.value', '');
+ });
+ });
+ });
+
+ it('should render and handle quick replies', () => {
+ cy.intercept('POST', '/rest/ai-assistant/chat', {
+ statusCode: 200,
+ fixture: 'aiAssistant/quick_reply_message_response.json',
+ }).as('chatRequest');
+ cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
+ wf.actions.openNode('Stop and Error');
+ ndv.getters.nodeExecuteButton().click();
+ aiAssistant.getters.nodeErrorViewAssistantButton().click();
+ cy.wait('@chatRequest');
+ aiAssistant.getters.quickReplies().should('have.length', 2);
+ aiAssistant.getters.quickReplies().eq(0).click();
+ cy.wait('@chatRequest');
+ aiAssistant.getters.chatMessagesUser().should('have.length', 1);
+ aiAssistant.getters.chatMessagesUser().eq(0).should('contain.text', "Sure, let's do it");
+ });
+
+ it('should send message to assistant when node is executed', () => {
+ cy.intercept('POST', '/rest/ai-assistant/chat', {
+ statusCode: 200,
+ fixture: 'aiAssistant/simple_message_response.json',
+ }).as('chatRequest');
+ cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
+ wf.actions.openNode('Edit Fields');
+ ndv.getters.nodeExecuteButton().click();
+ aiAssistant.getters.nodeErrorViewAssistantButton().click();
+ cy.wait('@chatRequest');
+ aiAssistant.getters.chatMessagesAssistant().should('have.length', 1);
+ // Executing the same node should sende a new message to the assistant automatically
+ ndv.getters.nodeExecuteButton().click();
+ cy.wait('@chatRequest');
+ aiAssistant.getters.chatMessagesAssistant().should('have.length', 2);
+ });
+
+ it('should warn before starting a new session', () => {
+ cy.intercept('POST', '/rest/ai-assistant/chat', {
+ statusCode: 200,
+ fixture: 'aiAssistant/simple_message_response.json',
+ }).as('chatRequest');
+ cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
+ wf.actions.openNode('Edit Fields');
+ ndv.getters.nodeExecuteButton().click();
+ aiAssistant.getters.nodeErrorViewAssistantButton().click();
+ cy.wait('@chatRequest');
+ aiAssistant.getters.closeChatButton().click();
+ ndv.getters.backToCanvas().click();
+ wf.actions.openNode('Stop and Error');
+ ndv.getters.nodeExecuteButton().click();
+ aiAssistant.getters.nodeErrorViewAssistantButton().click();
+ // Since we already have an active session, a warning should be shown
+ aiAssistant.getters.newAssistantSessionModal().should('be.visible');
+ aiAssistant.getters
+ .newAssistantSessionModal()
+ .find('button')
+ .contains('Start new session')
+ .click();
+ cy.wait('@chatRequest');
+ // New session should start with initial assistant message
+ aiAssistant.getters.chatMessagesAll().should('have.length', 1);
+ });
+
+ it('should apply code diff to code node', () => {
+ cy.intercept('POST', '/rest/ai-assistant/chat', {
+ statusCode: 200,
+ fixture: 'aiAssistant/code_diff_suggestion_response.json',
+ }).as('chatRequest');
+ cy.intercept('POST', '/rest/ai-assistant/chat/apply-suggestion', {
+ statusCode: 200,
+ fixture: 'aiAssistant/apply_code_diff_response.json',
+ }).as('applySuggestion');
+ cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
+ wf.actions.openNode('Code');
+ ndv.getters.nodeExecuteButton().click();
+ aiAssistant.getters.nodeErrorViewAssistantButton().click({ force: true });
+ cy.wait('@chatRequest');
+ // Should have two assistant messages
+ aiAssistant.getters.chatMessagesAll().should('have.length', 2);
+ aiAssistant.getters.codeDiffs().should('have.length', 1);
+ aiAssistant.getters.applyCodeDiffButtons().should('have.length', 1);
+ aiAssistant.getters.applyCodeDiffButtons().first().click();
+ cy.wait('@applySuggestion');
+ aiAssistant.getters.applyCodeDiffButtons().should('have.length', 0);
+ aiAssistant.getters.undoReplaceCodeButtons().should('have.length', 1);
+ aiAssistant.getters.codeReplacedMessage().should('be.visible');
+ ndv.getters
+ .parameterInput('jsCode')
+ .get('.cm-content')
+ .should('contain.text', 'item.json.myNewField = 1');
+ // Clicking undo should revert the code back but not call the assistant
+ aiAssistant.getters.undoReplaceCodeButtons().first().click();
+ aiAssistant.getters.applyCodeDiffButtons().should('have.length', 1);
+ aiAssistant.getters.codeReplacedMessage().should('not.exist');
+ cy.get('@applySuggestion.all').then((interceptions) => {
+ expect(interceptions).to.have.length(1);
+ });
+ ndv.getters
+ .parameterInput('jsCode')
+ .get('.cm-content')
+ .should('contain.text', 'item.json.myNewField = 1aaa');
+ // Replacing the code again should also not call the assistant
+ cy.get('@applySuggestion.all').then((interceptions) => {
+ expect(interceptions).to.have.length(1);
+ });
+ aiAssistant.getters.applyCodeDiffButtons().should('have.length', 1);
+ aiAssistant.getters.applyCodeDiffButtons().first().click();
+ ndv.getters
+ .parameterInput('jsCode')
+ .get('.cm-content')
+ .should('contain.text', 'item.json.myNewField = 1');
+ });
+
+ it('should end chat session when `end_session` event is received', () => {
+ cy.intercept('POST', '/rest/ai-assistant/chat', {
+ statusCode: 200,
+ fixture: 'aiAssistant/end_session_response.json',
+ }).as('chatRequest');
+ cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
+ wf.actions.openNode('Stop and Error');
+ ndv.getters.nodeExecuteButton().click();
+ aiAssistant.getters.nodeErrorViewAssistantButton().click();
+ cy.wait('@chatRequest');
+ aiAssistant.getters.chatMessagesSystem().should('have.length', 1);
+ aiAssistant.getters.chatMessagesSystem().first().should('contain.text', 'session has ended');
+ });
+});
diff --git a/cypress/fixtures/aiAssistant/apply_code_diff_response.json b/cypress/fixtures/aiAssistant/apply_code_diff_response.json
new file mode 100644
index 0000000000..8d7ada0b40
--- /dev/null
+++ b/cypress/fixtures/aiAssistant/apply_code_diff_response.json
@@ -0,0 +1,8 @@
+{
+ "data": {
+ "sessionId": "f9130bd7-c078-4862-a38a-369b27b0ff20-e96eb9f7-d581-4684-b6a9-fd3dfe9fe1fb-emTezIGat7bQsDdtIlbti",
+ "parameters": {
+ "jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1;\n}\n\nreturn $input.all();"
+ }
+ }
+}
diff --git a/cypress/fixtures/aiAssistant/code_diff_suggestion_response.json b/cypress/fixtures/aiAssistant/code_diff_suggestion_response.json
new file mode 100644
index 0000000000..8ee5d647fd
--- /dev/null
+++ b/cypress/fixtures/aiAssistant/code_diff_suggestion_response.json
@@ -0,0 +1,23 @@
+{
+ "sessionId": "1",
+ "messages": [
+ {
+ "role": "assistant",
+ "type": "message",
+ "text": "Hi there! Here is my top solution to fix the error in your **Code** node 👇"
+ },
+ {
+ "type": "code-diff",
+ "description": "Fix the syntax error by changing '1asd' to a valid value. In this case, it seems like '1' was intended.",
+ "suggestionId": "1",
+ "codeDiff": "@@ -2,2 +2,2 @@\n item.json.myNewField = 1asd;\n+ item.json.myNewField = 1;\n",
+ "role": "assistant",
+ "quickReplies": [
+ {
+ "text": "Give me another solution",
+ "type": "new-suggestion"
+ }
+ ]
+ }
+ ]
+}
diff --git a/cypress/fixtures/aiAssistant/end_session_response.json b/cypress/fixtures/aiAssistant/end_session_response.json
new file mode 100644
index 0000000000..c53574d93a
--- /dev/null
+++ b/cypress/fixtures/aiAssistant/end_session_response.json
@@ -0,0 +1,16 @@
+{
+ "sessionId": "f9130bd7-c078-4862-a38a-369b27b0ff20-e96eb9f7-d581-4684-b6a9-fd3dfe9fe1fb-XCldJLlusGrEVku5I9cYT",
+ "messages": [
+ {
+ "role": "assistant",
+ "type": "agent-suggestion",
+ "title": "Glad to Help",
+ "text": "I'm glad I could help. If you have any more questions or need further assistance with your n8n workflows, feel free to ask!"
+ },
+ {
+ "role": "assistant",
+ "type": "event",
+ "eventName": "end-session"
+ }
+ ]
+}
diff --git a/cypress/fixtures/aiAssistant/quick_reply_message_response.json b/cypress/fixtures/aiAssistant/quick_reply_message_response.json
new file mode 100644
index 0000000000..a3c1b958c4
--- /dev/null
+++ b/cypress/fixtures/aiAssistant/quick_reply_message_response.json
@@ -0,0 +1,20 @@
+{
+ "sessionId": "1",
+ "messages": [
+ {
+ "role": "assistant",
+ "type": "message",
+ "text": "Hey, this is an assistant message",
+ "quickReplies": [
+ {
+ "text": "Sure, let's do it",
+ "type": "yes"
+ },
+ {
+ "text": "Nah, doesn't sound good",
+ "type": "no"
+ }
+ ]
+ }
+ ]
+}
diff --git a/cypress/fixtures/aiAssistant/simple_message_response.json b/cypress/fixtures/aiAssistant/simple_message_response.json
new file mode 100644
index 0000000000..11299b91f9
--- /dev/null
+++ b/cypress/fixtures/aiAssistant/simple_message_response.json
@@ -0,0 +1,10 @@
+{
+ "sessionId": "1",
+ "messages": [
+ {
+ "role": "assistant",
+ "type": "message",
+ "text": "Hey, this is an assistant message"
+ }
+ ]
+}
diff --git a/cypress/fixtures/aiAssistant/test_workflow.json b/cypress/fixtures/aiAssistant/test_workflow.json
new file mode 100644
index 0000000000..da930ea489
--- /dev/null
+++ b/cypress/fixtures/aiAssistant/test_workflow.json
@@ -0,0 +1,88 @@
+{
+ "nodes": [
+ {
+ "parameters": {},
+ "id": "ebfced75-2ce1-4c41-a971-6c3b83522c4d",
+ "name": "When clicking ‘Test workflow’",
+ "type": "n8n-nodes-base.manualTrigger",
+ "typeVersion": 1,
+ "position": [
+ 360,
+ 220
+ ]
+ },
+ {
+ "parameters": {
+ "errorMessage": "This is an error message"
+ },
+ "id": "f2e60459-401a-49d5-acfc-7b2b31cfdcf7",
+ "name": "Stop and Error",
+ "type": "n8n-nodes-base.stopAndError",
+ "typeVersion": 1,
+ "position": [
+ 1020,
+ 220
+ ]
+ },
+ {
+ "parameters": {
+ "jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1aaa;\n}\n\nreturn $input.all();"
+ },
+ "id": "b54d4db9-b257-41a8-862f-26d293115bad",
+ "name": "Code",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 840,
+ 320
+ ]
+ },
+ {
+ "parameters": {
+ "assignments": {
+ "assignments": [
+ {
+ "id": "053ada73-f7db-4e6a-8cc8-85756cc6ca4e",
+ "name": "age",
+ "value": "={{ 32sad }}",
+ "type": "number"
+ }
+ ]
+ },
+ "options": {}
+ },
+ "id": "5fd89612-a871-4679-b7b0-d659e09c6a0e",
+ "name": "Edit Fields",
+ "type": "n8n-nodes-base.set",
+ "typeVersion": 3.4,
+ "position": [
+ 600,
+ 100
+ ]
+ }
+ ],
+ "connections": {
+ "When clicking ‘Test workflow’": {
+ "main": [
+ [
+ {
+ "node": "Stop and Error",
+ "type": "main",
+ "index": 0
+ },
+ {
+ "node": "Code",
+ "type": "main",
+ "index": 0
+ },
+ {
+ "node": "Edit Fields",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ }
+ },
+ "pinData": {}
+}
diff --git a/cypress/package.json b/cypress/package.json
index 7740b5483f..5084e0b10c 100644
--- a/cypress/package.json
+++ b/cypress/package.json
@@ -14,7 +14,7 @@
"start": "cd ..; pnpm start"
},
"devDependencies": {
- "@types/lodash": "^4.14.195",
+ "@types/lodash": "catalog:",
"eslint-plugin-cypress": "^3.3.0",
"n8n-workflow": "workspace:*"
},
@@ -24,8 +24,8 @@
"cypress": "^13.11.0",
"cypress-otp": "^1.0.3",
"cypress-real-events": "^1.12.0",
- "lodash": "4.17.21",
- "nanoid": "3.3.6",
- "start-server-and-test": "^2.0.3"
+ "lodash": "catalog:",
+ "nanoid": "catalog:",
+ "start-server-and-test": "^2.0.5"
}
}
diff --git a/cypress/pages/features/ai-assistant.ts b/cypress/pages/features/ai-assistant.ts
new file mode 100644
index 0000000000..abca07fbbe
--- /dev/null
+++ b/cypress/pages/features/ai-assistant.ts
@@ -0,0 +1,49 @@
+import { overrideFeatureFlag } from '../../composables/featureFlags';
+import { BasePage } from '../base';
+
+const AI_ASSISTANT_FEATURE = {
+ name: 'aiAssistant',
+ experimentName: '021_ai_debug_helper',
+ enabledFor: 'variant',
+ disabledFor: 'control',
+};
+
+export class AIAssistant extends BasePage {
+ url = '/workflows/new';
+
+ getters = {
+ askAssistantFloatingButton: () => cy.getByTestId('ask-assistant-floating-button'),
+ askAssistantSidebar: () => cy.getByTestId('ask-assistant-sidebar'),
+ askAssistantSidebarResizer: () =>
+ this.getters.askAssistantSidebar().find('[class^=_resizer][data-dir=left]').first(),
+ askAssistantChat: () => cy.getByTestId('ask-assistant-chat'),
+ placeholderMessage: () => cy.getByTestId('placeholder-message'),
+ closeChatButton: () => cy.getByTestId('close-chat-button'),
+ chatInputWrapper: () => cy.getByTestId('chat-input-wrapper'),
+ chatInput: () => cy.getByTestId('chat-input'),
+ sendMessageButton: () => cy.getByTestId('send-message-button'),
+ chatMessagesAll: () => cy.get('[data-test-id^=chat-message]'),
+ chatMessagesAssistant: () => cy.getByTestId('chat-message-assistant'),
+ chatMessagesUser: () => cy.getByTestId('chat-message-user'),
+ chatMessagesSystem: () => cy.getByTestId('chat-message-system'),
+ quickReplies: () => cy.getByTestId('quick-replies').find('button'),
+ newAssistantSessionModal: () => cy.getByTestId('new-assistant-session-modal'),
+ codeDiffs: () => cy.getByTestId('code-diff-suggestion'),
+ applyCodeDiffButtons: () => cy.getByTestId('replace-code-button'),
+ undoReplaceCodeButtons: () => cy.getByTestId('undo-replace-button'),
+ codeReplacedMessage: () => cy.getByTestId('code-replaced-message'),
+ nodeErrorViewAssistantButton: () =>
+ cy.getByTestId('node-error-view-ask-assistant-button').find('button').first(),
+ };
+
+ actions = {
+ enableAssistant(): void {
+ overrideFeatureFlag(AI_ASSISTANT_FEATURE.experimentName, AI_ASSISTANT_FEATURE.enabledFor);
+ cy.enableFeature(AI_ASSISTANT_FEATURE.name);
+ },
+ disableAssistant(): void {
+ overrideFeatureFlag(AI_ASSISTANT_FEATURE.experimentName, AI_ASSISTANT_FEATURE.disabledFor);
+ cy.disableFeature(AI_ASSISTANT_FEATURE.name);
+ },
+ };
+}
diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts
index 018ec43a5d..8bd7ccf95f 100644
--- a/cypress/pages/ndv.ts
+++ b/cypress/pages/ndv.ts
@@ -24,6 +24,7 @@ export class NDV extends BasePage {
editPinnedDataButton: () => cy.getByTestId('ndv-edit-pinned-data'),
pinnedDataEditor: () => this.getters.outputPanel().find('.cm-editor .cm-scroller .cm-content'),
runDataPaneHeader: () => cy.getByTestId('run-data-pane-header'),
+ aiOutputModeToggle: () => cy.getByTestId('ai-output-mode-select'),
nodeOutputHint: () => cy.getByTestId('ndv-output-run-node-hint'),
savePinnedDataButton: () =>
this.getters.runDataPaneHeader().find('button').filter(':visible').contains('Save'),
@@ -137,6 +138,8 @@ export class NDV extends BasePage {
cy.getByTestId(`fixed-collection-${paramName}`),
schemaViewNode: () => cy.getByTestId('run-data-schema-node'),
schemaViewNodeName: () => cy.getByTestId('run-data-schema-node-name'),
+ expressionExpanders: () => cy.getByTestId('expander'),
+ expressionModalOutput: () => cy.getByTestId('expression-modal-output'),
};
actions = {
@@ -174,7 +177,7 @@ export class NDV extends BasePage {
this.getters.editPinnedDataButton().click();
this.getters.pinnedDataEditor().click();
- this.getters.pinnedDataEditor().type('{selectall}{backspace}').paste(JSON.stringify(data));
+ this.getters.pinnedDataEditor().invoke('text', '').paste(JSON.stringify(data));
this.actions.savePinnedData();
},
@@ -204,9 +207,9 @@ export class NDV extends BasePage {
const droppable = `[data-test-id="parameter-input-${parameterName}"]`;
cy.draganddrop(draggable, droppable);
},
- mapToParameter: (parameterName: string) => {
+ mapToParameter: (parameterName: string, position?: 'top' | 'center' | 'bottom') => {
const droppable = `[data-test-id="parameter-input-${parameterName}"]`;
- cy.draganddrop('', droppable);
+ cy.draganddrop('', droppable, { position });
},
switchInputMode: (type: 'Schema' | 'Table' | 'JSON' | 'Binary') => {
this.getters.inputDisplayMode().find('label').contains(type).click({ force: true });
diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts
index a7fa994289..1f353ab7c5 100644
--- a/cypress/support/commands.ts
+++ b/cypress/support/commands.ts
@@ -175,7 +175,7 @@ Cypress.Commands.add('drag', (selector, pos, options) => {
});
});
-Cypress.Commands.add('draganddrop', (draggableSelector, droppableSelector) => {
+Cypress.Commands.add('draganddrop', (draggableSelector, droppableSelector, options) => {
if (draggableSelector) {
cy.get(draggableSelector).should('exist');
}
@@ -197,7 +197,7 @@ Cypress.Commands.add('draganddrop', (draggableSelector, droppableSelector) => {
cy.get(droppableSelector).realMouseMove(0, 0);
cy.get(droppableSelector).realMouseMove(pageX, pageY);
cy.get(droppableSelector).realHover();
- cy.get(droppableSelector).realMouseUp();
+ cy.get(droppableSelector).realMouseUp({ position: options?.position ?? 'top' });
if (draggableSelector) {
cy.get(draggableSelector).realMouseUp();
}
diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts
index 3968a09b5b..4d5d7a7f9a 100644
--- a/cypress/support/e2e.ts
+++ b/cypress/support/e2e.ts
@@ -17,6 +17,8 @@ beforeEach(() => {
cy.window().then((win): void => {
win.localStorage.setItem('N8N_THEME', 'light');
+ win.localStorage.setItem('N8N_AUTOCOMPLETE_ONBOARDED', 'true');
+ win.localStorage.setItem('N8N_MAPPING_ONBOARDED', 'true');
});
cy.intercept('GET', '/rest/settings', (req) => {
diff --git a/cypress/support/index.ts b/cypress/support/index.ts
index 9819e7c3a1..7c1897b11f 100644
--- a/cypress/support/index.ts
+++ b/cypress/support/index.ts
@@ -12,6 +12,10 @@ interface SigninPayload {
password: string;
}
+interface DragAndDropOptions {
+ position: 'top' | 'center' | 'bottom';
+}
+
declare global {
namespace Cypress {
interface SuiteConfigOverrides {
@@ -56,7 +60,11 @@ declare global {
target: [number, number],
options?: { abs?: boolean; index?: number; realMouse?: boolean; clickToFinish?: boolean },
): void;
- draganddrop(draggableSelector: string, droppableSelector: string): void;
+ draganddrop(
+ draggableSelector: string,
+ droppableSelector: string,
+ options?: Partial,
+ ): void;
push(type: string, data: unknown): void;
shouldNotHaveConsoleErrors(): void;
window(): Chainable<
diff --git a/cypress/utils/popper.ts b/cypress/utils/popper.ts
index 5743c70f3e..43ef2997cf 100644
--- a/cypress/utils/popper.ts
+++ b/cypress/utils/popper.ts
@@ -3,7 +3,7 @@ export function getPopper() {
}
export function getVisiblePopper() {
- return getPopper().filter(':visible');
+ return getPopper().filter('[aria-hidden="false"]');
}
export function getVisibleSelect() {
diff --git a/docker/images/n8n/README.md b/docker/images/n8n/README.md
index d654596150..f7b45f9467 100644
--- a/docker/images/n8n/README.md
+++ b/docker/images/n8n/README.md
@@ -230,6 +230,4 @@ Before you upgrade to the latest version make sure to check here if there are an
## License
-n8n is [fair-code](https://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md).
-
-Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/).
+You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license)
diff --git a/package.json b/package.json
index 59bae47e99..545a7c851f 100644
--- a/package.json
+++ b/package.json
@@ -1,19 +1,19 @@
{
"name": "n8n-monorepo",
- "version": "1.52.0",
+ "version": "1.56.0",
"private": true,
"engines": {
- "node": ">=18.10",
- "pnpm": ">=9.1"
+ "node": ">=20.15",
+ "pnpm": ">=9.5"
},
- "packageManager": "pnpm@9.1.4",
+ "packageManager": "pnpm@9.6.0",
"scripts": {
"preinstall": "node scripts/block-npm-install.js",
"build": "turbo run build",
"build:backend": "turbo run build:backend",
"build:frontend": "turbo run build:frontend",
"build:nodes": "turbo run build:nodes",
- "typecheck": "turbo --filter=!n8n typecheck",
+ "typecheck": "turbo typecheck",
"dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat",
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core",
"clean": "turbo run clean --parallel",
@@ -40,7 +40,6 @@
"@n8n_io/eslint-config": "workspace:*",
"@types/jest": "^29.5.3",
"@types/supertest": "^6.0.2",
- "@vitest/coverage-v8": "^1.6.0",
"jest": "^29.6.2",
"jest-environment-jsdom": "^29.6.2",
"jest-expect-message": "^1.1.3",
@@ -56,11 +55,7 @@
"tsc-alias": "^1.8.7",
"tsc-watch": "^6.0.4",
"turbo": "2.0.6",
- "typescript": "*",
- "vite": "^5.2.12",
- "vitest": "^1.6.0",
- "vitest-mock-extended": "^1.3.1",
- "vue-tsc": "^2.0.19"
+ "typescript": "*"
},
"pnpm": {
"onlyBuiltDependencies": [
@@ -68,7 +63,6 @@
],
"overrides": {
"@types/node": "^18.16.16",
- "axios": "1.6.7",
"chokidar": "3.5.2",
"esbuild": "^0.20.2",
"formidable": "3.5.1",
diff --git a/packages/@n8n/benchmark/Dockerfile b/packages/@n8n/benchmark/Dockerfile
new file mode 100644
index 0000000000..5fa1aeae93
--- /dev/null
+++ b/packages/@n8n/benchmark/Dockerfile
@@ -0,0 +1,62 @@
+# syntax=docker/dockerfile:1
+FROM node:20.16.0 AS base
+
+# Install required dependencies
+RUN apt-get update && apt-get install -y gnupg2 curl
+
+# Add k6 GPG key and repository
+RUN mkdir -p /etc/apt/keyrings && \
+ curl -sS https://dl.k6.io/key.gpg | gpg --dearmor --yes -o /etc/apt/keyrings/k6.gpg && \
+ chmod a+x /etc/apt/keyrings/k6.gpg && \
+ echo "deb [signed-by=/etc/apt/keyrings/k6.gpg] https://dl.k6.io/deb stable main" | tee /etc/apt/sources.list.d/k6.list
+
+# Update and install k6
+RUN apt-get update && \
+ apt-get install -y k6 tini && \
+ apt-get clean && \
+ rm -rf /var/lib/apt/lists/*
+
+ENV PNPM_HOME="/pnpm"
+ENV PATH="$PNPM_HOME:$PATH"
+RUN corepack enable
+
+#
+# Builder
+FROM base AS builder
+
+WORKDIR /app
+
+COPY --chown=node:node ./pnpm-lock.yaml /app/pnpm-lock.yaml
+COPY --chown=node:node ./pnpm-workspace.yaml /app/pnpm-workspace.yaml
+COPY --chown=node:node ./package.json /app/package.json
+COPY --chown=node:node ./packages/@n8n/benchmark/package.json /app/packages/@n8n/benchmark/package.json
+COPY --chown=node:node ./patches /app/patches
+COPY --chown=node:node ./scripts /app/scripts
+
+RUN pnpm install --frozen-lockfile
+
+# TS config files
+COPY --chown=node:node ./tsconfig.json /app/tsconfig.json
+COPY --chown=node:node ./tsconfig.build.json /app/tsconfig.build.json
+COPY --chown=node:node ./tsconfig.backend.json /app/tsconfig.backend.json
+COPY --chown=node:node ./packages/@n8n/benchmark/tsconfig.json /app/packages/@n8n/benchmark/tsconfig.json
+COPY --chown=node:node ./packages/@n8n/benchmark/tsconfig.build.json /app/packages/@n8n/benchmark/tsconfig.build.json
+
+# Source files
+COPY --chown=node:node ./packages/@n8n/benchmark/src /app/packages/@n8n/benchmark/src
+COPY --chown=node:node ./packages/@n8n/benchmark/bin /app/packages/@n8n/benchmark/bin
+COPY --chown=node:node ./packages/@n8n/benchmark/scenarios /app/packages/@n8n/benchmark/scenarios
+
+WORKDIR /app/packages/@n8n/benchmark
+RUN pnpm build
+
+#
+# Runner
+FROM base AS runner
+
+COPY --from=builder /app /app
+
+WORKDIR /app/packages/@n8n/benchmark
+USER node
+
+ENTRYPOINT [ "/app/packages/@n8n/benchmark/bin/n8n-benchmark" ]
diff --git a/packages/@n8n/benchmark/README.md b/packages/@n8n/benchmark/README.md
new file mode 100644
index 0000000000..569bcf897f
--- /dev/null
+++ b/packages/@n8n/benchmark/README.md
@@ -0,0 +1,55 @@
+# n8n benchmarking tool
+
+Tool for executing benchmarks against an n8n instance.
+
+## Running locally with Docker
+
+Build the Docker image:
+
+```sh
+# Must be run in the repository root
+# k6 doesn't have an arm64 build available for linux, we need to build against amd64
+docker build --platform linux/amd64 -t n8n-benchmark -f packages/@n8n/benchmark/Dockerfile .
+```
+
+Run the image
+
+```sh
+docker run \
+ -e N8N_USER_EMAIL=user@n8n.io \
+ -e N8N_USER_PASSWORD=password \
+ # For macos, n8n running outside docker
+ -e N8N_BASE_URL=http://host.docker.internal:5678 \
+ n8n-benchmark
+```
+
+## Running locally without Docker
+
+Requirements:
+
+- [k6](https://grafana.com/docs/k6/latest/set-up/install-k6/)
+- Node.js v20 or higher
+
+```sh
+pnpm build
+
+# Run tests against http://localhost:5678 with specified email and password
+N8N_USER_EMAIL=user@n8n.io N8N_USER_PASSWORD=password ./bin/n8n-benchmark run
+
+# If you installed k6 using brew, you might have to specify it explicitly
+K6_PATH=/opt/homebrew/bin/k6 N8N_USER_EMAIL=user@n8n.io N8N_USER_PASSWORD=password ./bin/n8n-benchmark run
+```
+
+## Configuration
+
+The configuration options the cli accepts can be seen from [config.ts](./src/config/config.ts)
+
+## Benchmark scenarios
+
+A benchmark scenario defines one or multiple steps to execute and measure. It consists of:
+
+- Manifest file which describes and configures the scenario
+- Any test data that is imported before the scenario is run
+- A [`k6`](https://grafana.com/docs/k6/latest/using-k6/http-requests/) script which executes the steps and receives `API_BASE_URL` environment variable in runtime.
+
+Available scenarios are located in [`./scenarios`](./scenarios/).
diff --git a/packages/@n8n/benchmark/bin/n8n-benchmark b/packages/@n8n/benchmark/bin/n8n-benchmark
new file mode 100755
index 0000000000..c7f0996f09
--- /dev/null
+++ b/packages/@n8n/benchmark/bin/n8n-benchmark
@@ -0,0 +1,13 @@
+#!/usr/bin/env node
+
+// Check if version should be displayed
+const versionFlags = ['-v', '-V', '--version'];
+if (versionFlags.includes(process.argv.slice(-1)[0])) {
+ console.log(require('../package').version);
+ process.exit(0);
+}
+
+(async () => {
+ const oclif = require('@oclif/core');
+ await oclif.execute({ dir: __dirname });
+})();
diff --git a/packages/@n8n/benchmark/package.json b/packages/@n8n/benchmark/package.json
new file mode 100644
index 0000000000..3a7afb50a3
--- /dev/null
+++ b/packages/@n8n/benchmark/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "@n8n/n8n-benchmark",
+ "version": "1.0.0",
+ "description": "Cli for running benchmark tests for n8n",
+ "main": "dist/index",
+ "scripts": {
+ "build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
+ "start": "./bin/n8n-benchmark",
+ "test": "echo \"Error: no test specified\" && exit 1",
+ "typecheck": "tsc --noEmit",
+ "watch": "concurrently \"tsc -w -p tsconfig.build.json\" \"tsc-alias -w -p tsconfig.build.json\""
+ },
+ "engines": {
+ "node": ">=20.10"
+ },
+ "keywords": [
+ "automate",
+ "automation",
+ "IaaS",
+ "iPaaS",
+ "n8n",
+ "workflow",
+ "benchmark",
+ "performance"
+ ],
+ "dependencies": {
+ "@oclif/core": "4.0.7",
+ "axios": "catalog:",
+ "convict": "6.2.4",
+ "dotenv": "8.6.0",
+ "zx": "^8.1.4"
+ },
+ "devDependencies": {
+ "@types/convict": "^6.1.1",
+ "@types/k6": "^0.52.0",
+ "@types/node": "^20.14.8",
+ "tsc-alias": "^1.8.7",
+ "typescript": "^5.5.2"
+ },
+ "bin": {
+ "n8n-benchmark": "./bin/n8n-benchmark"
+ },
+ "oclif": {
+ "bin": "n8n-benchmark",
+ "commands": "./dist/commands",
+ "topicSeparator": " "
+ }
+}
diff --git a/packages/@n8n/benchmark/scenarios/scenario.schema.json b/packages/@n8n/benchmark/scenarios/scenario.schema.json
new file mode 100644
index 0000000000..661fc054b6
--- /dev/null
+++ b/packages/@n8n/benchmark/scenarios/scenario.schema.json
@@ -0,0 +1,42 @@
+{
+ "definitions": {
+ "ScenarioData": {
+ "type": "object",
+ "properties": {
+ "workflowFiles": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ "required": [],
+ "additionalProperties": false
+ }
+ },
+ "type": "object",
+ "properties": {
+ "$schema": {
+ "type": "string",
+ "description": "The JSON schema to validate this file"
+ },
+ "name": {
+ "type": "string",
+ "description": "The name of the scenario"
+ },
+ "description": {
+ "type": "string",
+ "description": "A longer description of the scenario"
+ },
+ "scriptPath": {
+ "type": "string",
+ "description": "Relative path to the k6 test script"
+ },
+ "scenarioData": {
+ "$ref": "#/definitions/ScenarioData",
+ "description": "Data to import before running the scenario"
+ }
+ },
+ "required": ["name", "description", "scriptPath", "scenarioData"],
+ "additionalProperties": false
+}
diff --git a/packages/@n8n/benchmark/scenarios/singleWebhook/singleWebhook.json b/packages/@n8n/benchmark/scenarios/singleWebhook/singleWebhook.json
new file mode 100644
index 0000000000..cba1aa5832
--- /dev/null
+++ b/packages/@n8n/benchmark/scenarios/singleWebhook/singleWebhook.json
@@ -0,0 +1,25 @@
+{
+ "createdAt": "2024-08-06T12:19:51.268Z",
+ "updatedAt": "2024-08-06T12:20:45.000Z",
+ "name": "Single Webhook",
+ "active": true,
+ "nodes": [
+ {
+ "parameters": { "path": "single-webhook", "options": {} },
+ "id": "7587ab0e-cc15-424f-83c0-c887a0eb97fb",
+ "name": "Webhook",
+ "type": "n8n-nodes-base.webhook",
+ "typeVersion": 2,
+ "position": [760, 400],
+ "webhookId": "fa563fc2-c73f-4631-99a1-39c16f1f858f"
+ }
+ ],
+ "connections": {},
+ "settings": { "executionOrder": "v1" },
+ "staticData": null,
+ "meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
+ "pinData": {},
+ "versionId": "840a38a1-ba37-433d-9f20-de73f5131a2b",
+ "triggerCount": 1,
+ "tags": []
+}
diff --git a/packages/@n8n/benchmark/scenarios/singleWebhook/singleWebhook.manifest.json b/packages/@n8n/benchmark/scenarios/singleWebhook/singleWebhook.manifest.json
new file mode 100644
index 0000000000..e9b4664a96
--- /dev/null
+++ b/packages/@n8n/benchmark/scenarios/singleWebhook/singleWebhook.manifest.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "../scenario.schema.json",
+ "name": "SingleWebhook",
+ "description": "A single webhook trigger that responds with a 200 status code",
+ "scenarioData": { "workflowFiles": ["singleWebhook.json"] },
+ "scriptPath": "singleWebhook.script.ts"
+}
diff --git a/packages/@n8n/benchmark/scenarios/singleWebhook/singleWebhook.script.ts b/packages/@n8n/benchmark/scenarios/singleWebhook/singleWebhook.script.ts
new file mode 100644
index 0000000000..72e2563cbe
--- /dev/null
+++ b/packages/@n8n/benchmark/scenarios/singleWebhook/singleWebhook.script.ts
@@ -0,0 +1,11 @@
+import http from 'k6/http';
+import { check } from 'k6';
+
+const apiBaseUrl = __ENV.API_BASE_URL;
+
+export default function () {
+ const res = http.get(`${apiBaseUrl}/webhook/single-webhook`);
+ check(res, {
+ 'is status 200': (r) => r.status === 200,
+ });
+}
diff --git a/packages/@n8n/benchmark/src/commands/list.ts b/packages/@n8n/benchmark/src/commands/list.ts
new file mode 100644
index 0000000000..fcc60b1b81
--- /dev/null
+++ b/packages/@n8n/benchmark/src/commands/list.ts
@@ -0,0 +1,21 @@
+import { Command } from '@oclif/core';
+import { ScenarioLoader } from '@/scenario/scenarioLoader';
+import { loadConfig } from '@/config/config';
+
+export default class ListCommand extends Command {
+ static description = 'List all available scenarios';
+
+ async run() {
+ const config = loadConfig();
+ const scenarioLoader = new ScenarioLoader();
+
+ const allScenarios = scenarioLoader.loadAll(config.get('testScenariosPath'));
+
+ console.log('Available test scenarios:');
+ console.log('');
+
+ for (const scenario of allScenarios) {
+ console.log('\t', scenario.name, ':', scenario.description);
+ }
+ }
+}
diff --git a/packages/@n8n/benchmark/src/commands/run.ts b/packages/@n8n/benchmark/src/commands/run.ts
new file mode 100644
index 0000000000..d69b4a54d4
--- /dev/null
+++ b/packages/@n8n/benchmark/src/commands/run.ts
@@ -0,0 +1,39 @@
+import { Command, Flags } from '@oclif/core';
+import { loadConfig } from '@/config/config';
+import { ScenarioLoader } from '@/scenario/scenarioLoader';
+import { ScenarioRunner } from '@/testExecution/scenarioRunner';
+import { N8nApiClient } from '@/n8nApiClient/n8nApiClient';
+import { ScenarioDataFileLoader } from '@/scenario/scenarioDataLoader';
+import { K6Executor } from '@/testExecution/k6Executor';
+
+export default class RunCommand extends Command {
+ static description = 'Run all (default) or specified test scenarios';
+
+ // TODO: Add support for filtering scenarios
+ static flags = {
+ scenarios: Flags.string({
+ char: 't',
+ description: 'Comma-separated list of test scenarios to run',
+ required: false,
+ }),
+ };
+
+ async run() {
+ const config = loadConfig();
+ const scenarioLoader = new ScenarioLoader();
+
+ const scenarioRunner = new ScenarioRunner(
+ new N8nApiClient(config.get('n8n.baseUrl')),
+ new ScenarioDataFileLoader(),
+ new K6Executor(config.get('k6ExecutablePath'), config.get('n8n.baseUrl')),
+ {
+ email: config.get('n8n.user.email'),
+ password: config.get('n8n.user.password'),
+ },
+ );
+
+ const allScenarios = scenarioLoader.loadAll(config.get('testScenariosPath'));
+
+ await scenarioRunner.runManyScenarios(allScenarios);
+ }
+}
diff --git a/packages/@n8n/benchmark/src/config/config.ts b/packages/@n8n/benchmark/src/config/config.ts
new file mode 100644
index 0000000000..896ecc9296
--- /dev/null
+++ b/packages/@n8n/benchmark/src/config/config.ts
@@ -0,0 +1,50 @@
+import convict from 'convict';
+import dotenv from 'dotenv';
+
+dotenv.config();
+
+const configSchema = {
+ testScenariosPath: {
+ doc: 'The path to the scenarios',
+ format: String,
+ default: 'scenarios',
+ },
+ n8n: {
+ baseUrl: {
+ doc: 'The base URL for the n8n instance',
+ format: String,
+ default: 'http://localhost:5678',
+ env: 'N8N_BASE_URL',
+ },
+ user: {
+ email: {
+ doc: 'The email address of the n8n user',
+ format: String,
+ default: 'benchmark-user@n8n.io',
+ env: 'N8N_USER_EMAIL',
+ },
+ password: {
+ doc: 'The password of the n8n user',
+ format: String,
+ default: 'VerySecret!123',
+ env: 'N8N_USER_PASSWORD',
+ },
+ },
+ },
+ k6ExecutablePath: {
+ doc: 'The path to the k6 binary',
+ format: String,
+ default: 'k6',
+ env: 'K6_PATH',
+ },
+};
+
+export type Config = ReturnType;
+
+export function loadConfig() {
+ const config = convict(configSchema);
+
+ config.validate({ allowed: 'strict' });
+
+ return config;
+}
diff --git a/packages/@n8n/benchmark/src/n8nApiClient/authenticatedN8nApiClient.ts b/packages/@n8n/benchmark/src/n8nApiClient/authenticatedN8nApiClient.ts
new file mode 100644
index 0000000000..93cd767347
--- /dev/null
+++ b/packages/@n8n/benchmark/src/n8nApiClient/authenticatedN8nApiClient.ts
@@ -0,0 +1,67 @@
+import { strict as assert } from 'node:assert';
+import { N8nApiClient } from './n8nApiClient';
+import { AxiosRequestConfig } from 'axios';
+
+export class AuthenticatedN8nApiClient extends N8nApiClient {
+ constructor(
+ apiBaseUrl: string,
+ private readonly authCookie: string,
+ ) {
+ super(apiBaseUrl);
+ }
+
+ static async createUsingUsernameAndPassword(
+ apiClient: N8nApiClient,
+ loginDetails: {
+ email: string;
+ password: string;
+ },
+ ) {
+ const response = await apiClient.restApiRequest('/login', {
+ method: 'POST',
+ data: loginDetails,
+ });
+
+ const cookieHeader = response.headers['set-cookie'];
+ const authCookie = Array.isArray(cookieHeader) ? cookieHeader.join('; ') : cookieHeader;
+ assert(authCookie);
+
+ return new AuthenticatedN8nApiClient(apiClient.apiBaseUrl, authCookie);
+ }
+
+ async get(endpoint: string) {
+ return await this.authenticatedRequest(endpoint, {
+ method: 'GET',
+ });
+ }
+
+ async post(endpoint: string, data: unknown) {
+ return await this.authenticatedRequest(endpoint, {
+ method: 'POST',
+ data,
+ });
+ }
+
+ async patch(endpoint: string, data: unknown) {
+ return await this.authenticatedRequest(endpoint, {
+ method: 'PATCH',
+ data,
+ });
+ }
+
+ async delete(endpoint: string) {
+ return await this.authenticatedRequest(endpoint, {
+ method: 'DELETE',
+ });
+ }
+
+ protected async authenticatedRequest(endpoint: string, init: Omit) {
+ return await this.restApiRequest(endpoint, {
+ ...init,
+ headers: {
+ ...init.headers,
+ cookie: this.authCookie,
+ },
+ });
+ }
+}
diff --git a/packages/@n8n/benchmark/src/n8nApiClient/n8nApiClient.ts b/packages/@n8n/benchmark/src/n8nApiClient/n8nApiClient.ts
new file mode 100644
index 0000000000..86ca52aff8
--- /dev/null
+++ b/packages/@n8n/benchmark/src/n8nApiClient/n8nApiClient.ts
@@ -0,0 +1,78 @@
+import axios, { AxiosError, AxiosRequestConfig } from 'axios';
+
+export class N8nApiClient {
+ constructor(public readonly apiBaseUrl: string) {}
+
+ async waitForInstanceToBecomeOnline(): Promise {
+ const HEALTH_ENDPOINT = 'healthz';
+ const START_TIME = Date.now();
+ const INTERVAL_MS = 1000;
+ const TIMEOUT_MS = 60_000;
+
+ while (Date.now() - START_TIME < TIMEOUT_MS) {
+ try {
+ const response = await axios.request({
+ url: `${this.apiBaseUrl}/${HEALTH_ENDPOINT}`,
+ method: 'GET',
+ });
+
+ if (response.status === 200 && response.data.status === 'ok') {
+ return;
+ }
+ } catch {}
+
+ console.log(`n8n instance not online yet, retrying in ${INTERVAL_MS / 1000} seconds...`);
+ await this.delay(INTERVAL_MS);
+ }
+
+ throw new Error(`n8n instance did not come online within ${TIMEOUT_MS / 1000} seconds`);
+ }
+
+ async setupOwnerIfNeeded(loginDetails: { email: string; password: string }) {
+ const response = await this.restApiRequest<{ message: string }>('/owner/setup', {
+ method: 'POST',
+ data: {
+ email: loginDetails.email,
+ password: loginDetails.password,
+ firstName: 'Test',
+ lastName: 'User',
+ },
+ // Don't throw on non-2xx responses
+ validateStatus: () => true,
+ });
+
+ const responsePayload = response.data;
+
+ if (response.status === 200) {
+ console.log('Owner setup successful');
+ } else if (response.status === 400) {
+ if (responsePayload.message === 'Instance owner already setup')
+ console.log('Owner already set up');
+ } else {
+ throw new Error(
+ `Owner setup failed with status ${response.status}: ${responsePayload.message}`,
+ );
+ }
+ }
+
+ async restApiRequest(endpoint: string, init: Omit) {
+ try {
+ return await axios.request({
+ ...init,
+ url: this.getRestEndpointUrl(endpoint),
+ });
+ } catch (e) {
+ const error = e as AxiosError;
+ console.error(`[ERROR] Request failed ${init.method} ${endpoint}`, error?.response?.data);
+ throw error;
+ }
+ }
+
+ protected getRestEndpointUrl(endpoint: string) {
+ return `${this.apiBaseUrl}/rest${endpoint}`;
+ }
+
+ private delay(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+ }
+}
diff --git a/packages/@n8n/benchmark/src/n8nApiClient/n8nApiClient.types.ts b/packages/@n8n/benchmark/src/n8nApiClient/n8nApiClient.types.ts
new file mode 100644
index 0000000000..ff6aa6930b
--- /dev/null
+++ b/packages/@n8n/benchmark/src/n8nApiClient/n8nApiClient.types.ts
@@ -0,0 +1,8 @@
+/**
+ * n8n workflow. This is a simplified version of the actual workflow object.
+ */
+export type Workflow = {
+ id: string;
+ name: string;
+ tags?: string[];
+};
diff --git a/packages/@n8n/benchmark/src/n8nApiClient/workflowsApiClient.ts b/packages/@n8n/benchmark/src/n8nApiClient/workflowsApiClient.ts
new file mode 100644
index 0000000000..18f2ecbcda
--- /dev/null
+++ b/packages/@n8n/benchmark/src/n8nApiClient/workflowsApiClient.ts
@@ -0,0 +1,31 @@
+import { Workflow } from '@/n8nApiClient/n8nApiClient.types';
+import { AuthenticatedN8nApiClient } from './authenticatedN8nApiClient';
+
+export class WorkflowApiClient {
+ constructor(private readonly apiClient: AuthenticatedN8nApiClient) {}
+
+ async getAllWorkflows(): Promise {
+ const response = await this.apiClient.get<{ count: number; data: Workflow[] }>('/workflows');
+
+ return response.data.data;
+ }
+
+ async createWorkflow(workflow: unknown): Promise {
+ const response = await this.apiClient.post<{ data: Workflow }>('/workflows', workflow);
+
+ return response.data.data;
+ }
+
+ async activateWorkflow(workflow: Workflow): Promise {
+ const response = await this.apiClient.patch<{ data: Workflow }>(`/workflows/${workflow.id}`, {
+ ...workflow,
+ active: true,
+ });
+
+ return response.data.data;
+ }
+
+ async deleteWorkflow(workflowId: Workflow['id']): Promise {
+ await this.apiClient.delete(`/workflows/${workflowId}`);
+ }
+}
diff --git a/packages/@n8n/benchmark/src/scenario/scenarioDataLoader.ts b/packages/@n8n/benchmark/src/scenario/scenarioDataLoader.ts
new file mode 100644
index 0000000000..43638a2e00
--- /dev/null
+++ b/packages/@n8n/benchmark/src/scenario/scenarioDataLoader.ts
@@ -0,0 +1,35 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { Scenario } from '@/types/scenario';
+import { Workflow } from '@/n8nApiClient/n8nApiClient.types';
+
+/**
+ * Loads scenario data files from FS
+ */
+export class ScenarioDataFileLoader {
+ async loadDataForScenario(scenario: Scenario): Promise<{
+ workflows: Workflow[];
+ }> {
+ const workflows = await Promise.all(
+ scenario.scenarioData.workflowFiles?.map((workflowFilePath) =>
+ this.loadSingleWorkflowFromFile(path.join(scenario.scenarioDirPath, workflowFilePath)),
+ ) ?? [],
+ );
+
+ return {
+ workflows,
+ };
+ }
+
+ private loadSingleWorkflowFromFile(workflowFilePath: string): Workflow {
+ const fileContent = fs.readFileSync(workflowFilePath, 'utf8');
+
+ try {
+ return JSON.parse(fileContent);
+ } catch (error) {
+ throw new Error(
+ `Failed to parse workflow file ${workflowFilePath}: ${error instanceof Error ? error.message : error}`,
+ );
+ }
+ }
+}
diff --git a/packages/@n8n/benchmark/src/scenario/scenarioLoader.ts b/packages/@n8n/benchmark/src/scenario/scenarioLoader.ts
new file mode 100644
index 0000000000..475ce495ac
--- /dev/null
+++ b/packages/@n8n/benchmark/src/scenario/scenarioLoader.ts
@@ -0,0 +1,67 @@
+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 {
+ /**
+ * Loads all scenarios from the given path
+ */
+ loadAll(pathToScenarios: string): Scenario[] {
+ pathToScenarios = path.resolve(pathToScenarios);
+ const scenarioFolders = fs
+ .readdirSync(pathToScenarios, { withFileTypes: true })
+ .filter((dirent) => dirent.isDirectory())
+ .map((dirent) => dirent.name);
+
+ const scenarios: Scenario[] = [];
+
+ for (const folder of scenarioFolders) {
+ const scenarioPath = path.join(pathToScenarios, folder);
+ const manifestFileName = `${folder}.manifest.json`;
+ const scenarioManifestPath = path.join(pathToScenarios, folder, manifestFileName);
+ if (!fs.existsSync(scenarioManifestPath)) {
+ console.warn(`Scenario at ${scenarioPath} is missing the ${manifestFileName} file`);
+ continue;
+ }
+
+ // Load the scenario manifest file
+ const [scenario, validationErrors] =
+ this.loadAndValidateScenarioManifest(scenarioManifestPath);
+ if (validationErrors) {
+ console.warn(
+ `Scenario at ${scenarioPath} has the following validation errors: ${validationErrors.join(', ')}`,
+ );
+ continue;
+ }
+
+ scenarios.push({
+ ...scenario,
+ id: this.formScenarioId(scenarioPath),
+ scenarioDirPath: scenarioPath,
+ });
+ }
+
+ return scenarios;
+ }
+
+ private loadAndValidateScenarioManifest(
+ scenarioManifestPath: string,
+ ): [ScenarioManifest, null] | [null, string[]] {
+ const scenario = JSON.parse(fs.readFileSync(scenarioManifestPath, 'utf8'));
+ const validationErrors: string[] = [];
+
+ if (!scenario.name) {
+ validationErrors.push(`Scenario at ${scenarioManifestPath} is missing a name`);
+ }
+ if (!scenario.description) {
+ validationErrors.push(`Scenario at ${scenarioManifestPath} is missing a description`);
+ }
+
+ return validationErrors.length === 0 ? [scenario, null] : [null, validationErrors];
+ }
+
+ private formScenarioId(scenarioPath: string): string {
+ return createHash('sha256').update(scenarioPath).digest('hex');
+ }
+}
diff --git a/packages/@n8n/benchmark/src/testExecution/k6Executor.ts b/packages/@n8n/benchmark/src/testExecution/k6Executor.ts
new file mode 100644
index 0000000000..903d06ca74
--- /dev/null
+++ b/packages/@n8n/benchmark/src/testExecution/k6Executor.ts
@@ -0,0 +1,28 @@
+import { $ } from 'zx';
+import { Scenario } from '@/types/scenario';
+
+/**
+ * Executes test scenarios using k6
+ */
+export class K6Executor {
+ constructor(
+ private readonly k6ExecutablePath: string,
+ private readonly n8nApiBaseUrl: string,
+ ) {}
+
+ async executeTestScenario(scenario: Scenario) {
+ // For 1 min with 5 virtual users
+ const stage = '1m:5';
+
+ const processPromise = $({
+ cwd: scenario.scenarioDirPath,
+ env: {
+ API_BASE_URL: this.n8nApiBaseUrl,
+ },
+ })`${this.k6ExecutablePath} run --quiet --stage ${stage} ${scenario.scriptPath}`;
+
+ for await (const chunk of processPromise.stdout) {
+ console.log(chunk.toString());
+ }
+ }
+}
diff --git a/packages/@n8n/benchmark/src/testExecution/scenarioDataImporter.ts b/packages/@n8n/benchmark/src/testExecution/scenarioDataImporter.ts
new file mode 100644
index 0000000000..1c1ec3777e
--- /dev/null
+++ b/packages/@n8n/benchmark/src/testExecution/scenarioDataImporter.ts
@@ -0,0 +1,56 @@
+import { AuthenticatedN8nApiClient } from '@/n8nApiClient/authenticatedN8nApiClient';
+import { Workflow } from '@/n8nApiClient/n8nApiClient.types';
+import { WorkflowApiClient } from '@/n8nApiClient/workflowsApiClient';
+
+/**
+ * Imports scenario data into an n8n instance
+ */
+export class ScenarioDataImporter {
+ private readonly workflowApiClient: WorkflowApiClient;
+
+ constructor(n8nApiClient: AuthenticatedN8nApiClient) {
+ this.workflowApiClient = new WorkflowApiClient(n8nApiClient);
+ }
+
+ async importTestScenarioData(workflows: Workflow[]) {
+ const existingWorkflows = await this.workflowApiClient.getAllWorkflows();
+
+ for (const workflow of workflows) {
+ await this.importWorkflow({ existingWorkflows, workflow });
+ }
+ }
+
+ /**
+ * Imports a single workflow into n8n removing any existing workflows with the same name
+ */
+ private async importWorkflow(opts: { existingWorkflows: Workflow[]; workflow: Workflow }) {
+ const existingWorkflows = this.findExistingWorkflows(opts.existingWorkflows, opts.workflow);
+ if (existingWorkflows.length > 0) {
+ for (const toDelete of existingWorkflows) {
+ await this.workflowApiClient.deleteWorkflow(toDelete.id);
+ }
+ }
+
+ const createdWorkflow = await this.workflowApiClient.createWorkflow({
+ ...opts.workflow,
+ name: this.getBenchmarkWorkflowName(opts.workflow),
+ });
+
+ return await this.workflowApiClient.activateWorkflow(createdWorkflow);
+ }
+
+ private findExistingWorkflows(
+ existingWorkflows: Workflow[],
+ workflowToImport: Workflow,
+ ): Workflow[] {
+ const benchmarkWorkflowName = this.getBenchmarkWorkflowName(workflowToImport);
+
+ return existingWorkflows.filter(
+ (existingWorkflow) => existingWorkflow.name === benchmarkWorkflowName,
+ );
+ }
+
+ private getBenchmarkWorkflowName(workflow: Workflow) {
+ return `[BENCHMARK] ${workflow.name}`;
+ }
+}
diff --git a/packages/@n8n/benchmark/src/testExecution/scenarioRunner.ts b/packages/@n8n/benchmark/src/testExecution/scenarioRunner.ts
new file mode 100644
index 0000000000..83e1077680
--- /dev/null
+++ b/packages/@n8n/benchmark/src/testExecution/scenarioRunner.ts
@@ -0,0 +1,50 @@
+import { Scenario } from '@/types/scenario';
+import { N8nApiClient } from '@/n8nApiClient/n8nApiClient';
+import { ScenarioDataFileLoader } from '@/scenario/scenarioDataLoader';
+import { K6Executor } from './k6Executor';
+import { ScenarioDataImporter } from '@/testExecution/scenarioDataImporter';
+import { AuthenticatedN8nApiClient } from '@/n8nApiClient/authenticatedN8nApiClient';
+
+/**
+ * Runs scenarios
+ */
+export class ScenarioRunner {
+ constructor(
+ private readonly n8nClient: N8nApiClient,
+ private readonly dataLoader: ScenarioDataFileLoader,
+ private readonly k6Executor: K6Executor,
+ private readonly ownerConfig: {
+ email: string;
+ password: string;
+ },
+ ) {}
+
+ async runManyScenarios(scenarios: Scenario[]) {
+ console.log(`Waiting for n8n ${this.n8nClient.apiBaseUrl} to become online`);
+ await this.n8nClient.waitForInstanceToBecomeOnline();
+
+ console.log('Setting up owner');
+ await this.n8nClient.setupOwnerIfNeeded(this.ownerConfig);
+
+ const authenticatedN8nClient = await AuthenticatedN8nApiClient.createUsingUsernameAndPassword(
+ this.n8nClient,
+ this.ownerConfig,
+ );
+ const testDataImporter = new ScenarioDataImporter(authenticatedN8nClient);
+
+ for (const scenario of scenarios) {
+ await this.runSingleTestScenario(testDataImporter, scenario);
+ }
+ }
+
+ private async runSingleTestScenario(testDataImporter: ScenarioDataImporter, scenario: Scenario) {
+ console.log('Running scenario:', scenario.name);
+
+ console.log('Loading and importing data');
+ const testData = await this.dataLoader.loadDataForScenario(scenario);
+ await testDataImporter.importTestScenarioData(testData.workflows);
+
+ console.log('Executing scenario script');
+ await this.k6Executor.executeTestScenario(scenario);
+ }
+}
diff --git a/packages/@n8n/benchmark/src/types/scenario.ts b/packages/@n8n/benchmark/src/types/scenario.ts
new file mode 100644
index 0000000000..19c52fd45b
--- /dev/null
+++ b/packages/@n8n/benchmark/src/types/scenario.ts
@@ -0,0 +1,27 @@
+export type ScenarioData = {
+ /** Relative paths to the workflow files */
+ workflowFiles?: string[];
+};
+
+/**
+ * Configuration that defines the benchmark scenario
+ */
+export type ScenarioManifest = {
+ /** The name of the scenario */
+ name: string;
+ /** A longer description of the scenario */
+ description: string;
+ /** Relative path to the k6 script */
+ scriptPath: string;
+ /** Data to import before running the scenario */
+ scenarioData: ScenarioData;
+};
+
+/**
+ * Scenario with additional metadata
+ */
+export type Scenario = ScenarioManifest & {
+ id: string;
+ /** Path to the directory containing the scenario */
+ scenarioDirPath: string;
+};
diff --git a/packages/@n8n/benchmark/tsconfig.build.json b/packages/@n8n/benchmark/tsconfig.build.json
new file mode 100644
index 0000000000..b91db37a4a
--- /dev/null
+++ b/packages/@n8n/benchmark/tsconfig.build.json
@@ -0,0 +1,9 @@
+{
+ "extends": ["./tsconfig.json", "../../../tsconfig.build.json"],
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "tsBuildInfoFile": "dist/build.tsbuildinfo"
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/@n8n/benchmark/tsconfig.json b/packages/@n8n/benchmark/tsconfig.json
new file mode 100644
index 0000000000..58a1b48f65
--- /dev/null
+++ b/packages/@n8n/benchmark/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": ["../../../tsconfig.json", "../../../tsconfig.backend.json"],
+ "compilerOptions": {
+ "rootDir": ".",
+ "baseUrl": "src",
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/@n8n/chat/README.md b/packages/@n8n/chat/README.md
index 538d72ce0a..0ed53a5774 100644
--- a/packages/@n8n/chat/README.md
+++ b/packages/@n8n/chat/README.md
@@ -260,10 +260,5 @@ body,
```
## License
-n8n Chat is [fair-code](https://faircode.io) distributed under the
-[**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md).
-Proprietary licenses are available for enterprise customers. [Get in touch](mailto:license@n8n.io)
-
-Additional information about the license model can be found in the
-[docs](https://docs.n8n.io/reference/license/).
+You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license)
diff --git a/packages/@n8n/chat/package.json b/packages/@n8n/chat/package.json
index 25b9212b3e..6508081f45 100644
--- a/packages/@n8n/chat/package.json
+++ b/packages/@n8n/chat/package.json
@@ -1,6 +1,6 @@
{
"name": "@n8n/chat",
- "version": "0.21.0",
+ "version": "0.24.0",
"scripts": {
"dev": "pnpm run storybook",
"build": "pnpm build:vite && pnpm build:bundle",
@@ -38,16 +38,19 @@
"@vueuse/core": "^10.11.0",
"highlight.js": "^11.8.0",
"markdown-it-link-attributes": "^4.0.1",
- "uuid": "^8.3.2",
- "vue": "^3.4.21",
- "vue-markdown-render": "^2.1.1"
+ "uuid": "catalog:",
+ "vue": "catalog:frontend",
+ "vue-markdown-render": "catalog:frontend"
},
"devDependencies": {
"@iconify-json/mdi": "^1.1.54",
"@n8n/storybook": "workspace:*",
- "@types/markdown-it": "^12.2.3",
+ "@vitest/coverage-v8": "catalog:frontend",
"unplugin-icons": "^0.19.0",
- "vite-plugin-dts": "^3.9.1"
+ "vite": "catalog:frontend",
+ "vitest": "catalog:frontend",
+ "vite-plugin-dts": "^3.9.1",
+ "vue-tsc": "catalog:frontend"
},
"files": [
"README.md",
diff --git a/packages/@n8n/chat/src/components/Input.vue b/packages/@n8n/chat/src/components/Input.vue
index 0415396082..af4b3343b7 100644
--- a/packages/@n8n/chat/src/components/Input.vue
+++ b/packages/@n8n/chat/src/components/Input.vue
@@ -247,6 +247,10 @@ function onOpenFileDialog() {
justify-content: center;
transition: color var(--chat--transition-duration) ease;
+ svg {
+ min-width: fit-content;
+ }
+
&:hover,
&:focus {
background: var(
diff --git a/packages/@n8n/client-oauth2/package.json b/packages/@n8n/client-oauth2/package.json
index 38ce65e8ee..7ec91b9839 100644
--- a/packages/@n8n/client-oauth2/package.json
+++ b/packages/@n8n/client-oauth2/package.json
@@ -1,6 +1,6 @@
{
"name": "@n8n/client-oauth2",
- "version": "0.19.0",
+ "version": "0.20.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
@@ -20,6 +20,6 @@
"dist/**/*"
],
"dependencies": {
- "axios": "1.6.7"
+ "axios": "catalog:"
}
}
diff --git a/packages/@n8n/config/.eslintrc.js b/packages/@n8n/config/.eslintrc.js
index 032e99b09e..e5a8f3f0f9 100644
--- a/packages/@n8n/config/.eslintrc.js
+++ b/packages/@n8n/config/.eslintrc.js
@@ -7,4 +7,13 @@ module.exports = {
extends: ['@n8n_io/eslint-config/node'],
...sharedOptions(__dirname),
+
+ overrides: [
+ {
+ files: ['**/*.config.ts'],
+ rules: {
+ 'n8n-local-rules/no-untyped-config-class-field': 'error',
+ },
+ },
+ ],
};
diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json
index d992cc5aee..36d280a71f 100644
--- a/packages/@n8n/config/package.json
+++ b/packages/@n8n/config/package.json
@@ -1,6 +1,6 @@
{
"name": "@n8n/config",
- "version": "1.2.0",
+ "version": "1.6.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
@@ -21,6 +21,6 @@
],
"dependencies": {
"reflect-metadata": "0.2.2",
- "typedi": "0.10.0"
+ "typedi": "catalog:"
}
}
diff --git a/packages/@n8n/config/src/configs/cache.config.ts b/packages/@n8n/config/src/configs/cache.config.ts
new file mode 100644
index 0000000000..fa1295d583
--- /dev/null
+++ b/packages/@n8n/config/src/configs/cache.config.ts
@@ -0,0 +1,36 @@
+import { Config, Env, Nested } from '../decorators';
+
+@Config
+class MemoryConfig {
+ /** Max size of memory cache in bytes */
+ @Env('N8N_CACHE_MEMORY_MAX_SIZE')
+ maxSize: number = 3 * 1024 * 1024; // 3 MiB
+
+ /** Time to live (in milliseconds) for data cached in memory. */
+ @Env('N8N_CACHE_MEMORY_TTL')
+ ttl: number = 3600 * 1000; // 1 hour
+}
+
+@Config
+class RedisConfig {
+ /** Prefix for cache keys in Redis. */
+ @Env('N8N_CACHE_REDIS_KEY_PREFIX')
+ prefix: string = 'redis';
+
+ /** Time to live (in milliseconds) for data cached in Redis. 0 for no TTL. */
+ @Env('N8N_CACHE_REDIS_TTL')
+ ttl: number = 3600 * 1000; // 1 hour
+}
+
+@Config
+export class CacheConfig {
+ /** Backend to use for caching. */
+ @Env('N8N_CACHE_BACKEND')
+ backend: 'memory' | 'redis' | 'auto' = 'auto';
+
+ @Nested
+ memory: MemoryConfig;
+
+ @Nested
+ redis: RedisConfig;
+}
diff --git a/packages/@n8n/config/src/configs/credentials.ts b/packages/@n8n/config/src/configs/credentials.config.ts
similarity index 77%
rename from packages/@n8n/config/src/configs/credentials.ts
rename to packages/@n8n/config/src/configs/credentials.config.ts
index 9659061c05..f40e6b9f15 100644
--- a/packages/@n8n/config/src/configs/credentials.ts
+++ b/packages/@n8n/config/src/configs/credentials.config.ts
@@ -7,19 +7,19 @@ class CredentialsOverwrite {
* Format: { CREDENTIAL_NAME: { PARAMETER: VALUE }}
*/
@Env('CREDENTIALS_OVERWRITE_DATA')
- readonly data: string = '{}';
+ data: string = '{}';
/** Internal API endpoint to fetch overwritten credential types from. */
@Env('CREDENTIALS_OVERWRITE_ENDPOINT')
- readonly endpoint: string = '';
+ endpoint: string = '';
}
@Config
export class CredentialsConfig {
/** Default name for credentials */
@Env('CREDENTIALS_DEFAULT_NAME')
- readonly defaultName: string = 'My credentials';
+ defaultName: string = 'My credentials';
@Nested
- readonly overwrite: CredentialsOverwrite;
+ overwrite: CredentialsOverwrite;
}
diff --git a/packages/@n8n/config/src/configs/database.ts b/packages/@n8n/config/src/configs/database.config.ts
similarity index 70%
rename from packages/@n8n/config/src/configs/database.ts
rename to packages/@n8n/config/src/configs/database.config.ts
index 384ecb1fb0..9da83958e6 100644
--- a/packages/@n8n/config/src/configs/database.ts
+++ b/packages/@n8n/config/src/configs/database.config.ts
@@ -4,19 +4,19 @@ import { Config, Env, Nested } from '../decorators';
class LoggingConfig {
/** Whether database logging is enabled. */
@Env('DB_LOGGING_ENABLED')
- readonly enabled: boolean = false;
+ enabled: boolean = false;
/**
* Database logging level. Requires `DB_LOGGING_MAX_EXECUTION_TIME` to be higher than `0`.
*/
@Env('DB_LOGGING_OPTIONS')
- readonly options: 'query' | 'error' | 'schema' | 'warn' | 'info' | 'log' | 'all' = 'error';
+ options: 'query' | 'error' | 'schema' | 'warn' | 'info' | 'log' | 'all' = 'error';
/**
* Only queries that exceed this time (ms) will be logged. Set `0` to disable.
*/
@Env('DB_LOGGING_MAX_EXECUTION_TIME')
- readonly maxQueryExecutionTime: number = 0;
+ maxQueryExecutionTime: number = 0;
}
@Config
@@ -26,23 +26,23 @@ class PostgresSSLConfig {
* If `DB_POSTGRESDB_SSL_CA`, `DB_POSTGRESDB_SSL_CERT`, or `DB_POSTGRESDB_SSL_KEY` are defined, `DB_POSTGRESDB_SSL_ENABLED` defaults to `true`.
*/
@Env('DB_POSTGRESDB_SSL_ENABLED')
- readonly enabled: boolean = false;
+ enabled: boolean = false;
/** SSL certificate authority */
@Env('DB_POSTGRESDB_SSL_CA')
- readonly ca: string = '';
+ ca: string = '';
/** SSL certificate */
@Env('DB_POSTGRESDB_SSL_CERT')
- readonly cert: string = '';
+ cert: string = '';
/** SSL key */
@Env('DB_POSTGRESDB_SSL_KEY')
- readonly key: string = '';
+ key: string = '';
/** If unauthorized SSL connections should be rejected */
@Env('DB_POSTGRESDB_SSL_REJECT_UNAUTHORIZED')
- readonly rejectUnauthorized: boolean = true;
+ rejectUnauthorized: boolean = true;
}
@Config
@@ -53,30 +53,30 @@ class PostgresConfig {
/** Postgres database host */
@Env('DB_POSTGRESDB_HOST')
- readonly host: string = 'localhost';
+ host: string = 'localhost';
/** Postgres database password */
@Env('DB_POSTGRESDB_PASSWORD')
- readonly password: string = '';
+ password: string = '';
/** Postgres database port */
@Env('DB_POSTGRESDB_PORT')
- readonly port: number = 5432;
+ port: number = 5432;
/** Postgres database user */
@Env('DB_POSTGRESDB_USER')
- readonly user: string = 'postgres';
+ user: string = 'postgres';
/** Postgres database schema */
@Env('DB_POSTGRESDB_SCHEMA')
- readonly schema: string = 'public';
+ schema: string = 'public';
/** Postgres database pool size */
@Env('DB_POSTGRESDB_POOL_SIZE')
- readonly poolSize = 2;
+ poolSize: number = 2;
@Nested
- readonly ssl: PostgresSSLConfig;
+ ssl: PostgresSSLConfig;
}
@Config
@@ -87,36 +87,36 @@ class MysqlConfig {
/** MySQL database host */
@Env('DB_MYSQLDB_HOST')
- readonly host: string = 'localhost';
+ host: string = 'localhost';
/** MySQL database password */
@Env('DB_MYSQLDB_PASSWORD')
- readonly password: string = '';
+ password: string = '';
/** MySQL database port */
@Env('DB_MYSQLDB_PORT')
- readonly port: number = 3306;
+ port: number = 3306;
/** MySQL database user */
@Env('DB_MYSQLDB_USER')
- readonly user: string = 'root';
+ user: string = 'root';
}
@Config
class SqliteConfig {
/** SQLite database file name */
@Env('DB_SQLITE_DATABASE')
- readonly database: string = 'database.sqlite';
+ database: string = 'database.sqlite';
/** SQLite database pool size. Set to `0` to disable pooling. */
@Env('DB_SQLITE_POOL_SIZE')
- readonly poolSize: number = 0;
+ poolSize: number = 0;
/**
* Enable SQLite WAL mode.
*/
@Env('DB_SQLITE_ENABLE_WAL')
- readonly enableWAL: boolean = this.poolSize > 1;
+ enableWAL: boolean = this.poolSize > 1;
/**
* Run `VACUUM` on startup to rebuild the database, reducing file size and optimizing indexes.
@@ -124,7 +124,7 @@ class SqliteConfig {
* @warning Long-running blocking operation that will increase startup time.
*/
@Env('DB_SQLITE_VACUUM_ON_STARTUP')
- readonly executeVacuumOnStartup: boolean = false;
+ executeVacuumOnStartup: boolean = false;
}
@Config
@@ -135,17 +135,17 @@ export class DatabaseConfig {
/** Prefix for table names */
@Env('DB_TABLE_PREFIX')
- readonly tablePrefix: string = '';
+ tablePrefix: string = '';
@Nested
- readonly logging: LoggingConfig;
+ logging: LoggingConfig;
@Nested
- readonly postgresdb: PostgresConfig;
+ postgresdb: PostgresConfig;
@Nested
- readonly mysqldb: MysqlConfig;
+ mysqldb: MysqlConfig;
@Nested
- readonly sqlite: SqliteConfig;
+ sqlite: SqliteConfig;
}
diff --git a/packages/@n8n/config/src/configs/endpoints.config.ts b/packages/@n8n/config/src/configs/endpoints.config.ts
new file mode 100644
index 0000000000..88efa01d26
--- /dev/null
+++ b/packages/@n8n/config/src/configs/endpoints.config.ts
@@ -0,0 +1,102 @@
+import { Config, Env, Nested } from '../decorators';
+
+@Config
+class PrometheusMetricsConfig {
+ /** Whether to enable the `/metrics` endpoint to expose Prometheus metrics. */
+ @Env('N8N_METRICS')
+ enable: boolean = false;
+
+ /** Prefix for Prometheus metric names. */
+ @Env('N8N_METRICS_PREFIX')
+ prefix: string = 'n8n_';
+
+ /** Whether to expose system and Node.js metrics. See: https://www.npmjs.com/package/prom-client */
+ @Env('N8N_METRICS_INCLUDE_DEFAULT_METRICS')
+ includeDefaultMetrics: boolean = true;
+
+ /** Whether to include a label for workflow ID on workflow metrics. */
+ @Env('N8N_METRICS_INCLUDE_WORKFLOW_ID_LABEL')
+ includeWorkflowIdLabel: boolean = false;
+
+ /** Whether to include a label for node type on node metrics. */
+ @Env('N8N_METRICS_INCLUDE_NODE_TYPE_LABEL')
+ includeNodeTypeLabel: boolean = false;
+
+ /** Whether to include a label for credential type on credential metrics. */
+ @Env('N8N_METRICS_INCLUDE_CREDENTIAL_TYPE_LABEL')
+ includeCredentialTypeLabel: boolean = false;
+
+ /** Whether to expose metrics for API endpoints. See: https://www.npmjs.com/package/express-prom-bundle */
+ @Env('N8N_METRICS_INCLUDE_API_ENDPOINTS')
+ includeApiEndpoints: boolean = false;
+
+ /** Whether to include a label for the path of API endpoint calls. */
+ @Env('N8N_METRICS_INCLUDE_API_PATH_LABEL')
+ includeApiPathLabel: boolean = false;
+
+ /** Whether to include a label for the HTTP method of API endpoint calls. */
+ @Env('N8N_METRICS_INCLUDE_API_METHOD_LABEL')
+ includeApiMethodLabel: boolean = false;
+
+ /** Whether to include a label for the status code of API endpoint calls. */
+ @Env('N8N_METRICS_INCLUDE_API_STATUS_CODE_LABEL')
+ includeApiStatusCodeLabel: boolean = false;
+
+ /** Whether to include metrics for cache hits and misses. */
+ @Env('N8N_METRICS_INCLUDE_CACHE_METRICS')
+ includeCacheMetrics: boolean = false;
+
+ /** Whether to include metrics derived from n8n's internal events */
+ @Env('N8N_METRICS_INCLUDE_MESSAGE_EVENT_BUS_METRICS')
+ includeMessageEventBusMetrics: boolean = false;
+}
+
+@Config
+export class EndpointsConfig {
+ /** Max payload size in MiB */
+ @Env('N8N_PAYLOAD_SIZE_MAX')
+ payloadSizeMax: number = 16;
+
+ @Nested
+ metrics: PrometheusMetricsConfig;
+
+ /** Path segment for REST API endpoints. */
+ @Env('N8N_ENDPOINT_REST')
+ rest: string = 'rest';
+
+ /** Path segment for form endpoints. */
+ @Env('N8N_ENDPOINT_FORM')
+ form: string = 'form';
+
+ /** Path segment for test form endpoints. */
+ @Env('N8N_ENDPOINT_FORM_TEST')
+ formTest: string = 'form-test';
+
+ /** Path segment for waiting form endpoints. */
+ @Env('N8N_ENDPOINT_FORM_WAIT')
+ formWaiting: string = 'form-waiting';
+
+ /** Path segment for webhook endpoints. */
+ @Env('N8N_ENDPOINT_WEBHOOK')
+ webhook: string = 'webhook';
+
+ /** Path segment for test webhook endpoints. */
+ @Env('N8N_ENDPOINT_WEBHOOK_TEST')
+ webhookTest: string = 'webhook-test';
+
+ /** Path segment for waiting webhook endpoints. */
+ @Env('N8N_ENDPOINT_WEBHOOK_WAIT')
+ webhookWaiting: string = 'webhook-waiting';
+
+ /** Whether to disable n8n's UI (frontend). */
+ @Env('N8N_DISABLE_UI')
+ disableUi: boolean = false;
+
+ /** Whether to disable production webhooks on the main process, when using webhook-specific processes. */
+ @Env('N8N_DISABLE_PRODUCTION_MAIN_PROCESS')
+ disableProductionWebhooksOnMainProcess: boolean = false;
+
+ /** Colon-delimited list of additional endpoints to not open the UI on. */
+ @Env('N8N_ADDITIONAL_NON_UI_ROUTES')
+ additionalNonUIRoutes: string = '';
+}
diff --git a/packages/@n8n/config/src/configs/event-bus.ts b/packages/@n8n/config/src/configs/event-bus.config.ts
similarity index 69%
rename from packages/@n8n/config/src/configs/event-bus.ts
rename to packages/@n8n/config/src/configs/event-bus.config.ts
index ed1226fa92..b4782555d5 100644
--- a/packages/@n8n/config/src/configs/event-bus.ts
+++ b/packages/@n8n/config/src/configs/event-bus.config.ts
@@ -2,30 +2,30 @@ import { Config, Env, Nested } from '../decorators';
@Config
class LogWriterConfig {
- /** Number of event log files to keep */
+ /* of event log files to keep */
@Env('N8N_EVENTBUS_LOGWRITER_KEEPLOGCOUNT')
- readonly keepLogCount: number = 3;
+ keepLogCount: number = 3;
/** Max size (in KB) of an event log file before a new one is started */
@Env('N8N_EVENTBUS_LOGWRITER_MAXFILESIZEINKB')
- readonly maxFileSizeInKB: number = 10240; // 10 MB
+ maxFileSizeInKB: number = 10240; // 10 MB
/** Basename of event log file */
@Env('N8N_EVENTBUS_LOGWRITER_LOGBASENAME')
- readonly logBaseName: string = 'n8nEventLog';
+ logBaseName: string = 'n8nEventLog';
}
@Config
export class EventBusConfig {
/** How often (in ms) to check for unsent event messages. Can in rare cases cause a message to be sent twice. `0` to disable */
@Env('N8N_EVENTBUS_CHECKUNSENTINTERVAL')
- readonly checkUnsentInterval: number = 0;
+ checkUnsentInterval: number = 0;
/** Endpoint to retrieve n8n version information from */
@Nested
- readonly logWriter: LogWriterConfig;
+ logWriter: LogWriterConfig;
/** Whether to recover execution details after a crash or only mark status executions as crashed. */
@Env('N8N_EVENTBUS_RECOVERY_MODE')
- readonly crashRecoveryMode: 'simple' | 'extensive' = 'extensive';
+ crashRecoveryMode: 'simple' | 'extensive' = 'extensive';
}
diff --git a/packages/@n8n/config/src/configs/external-secrets.ts b/packages/@n8n/config/src/configs/external-secrets.config.ts
similarity index 80%
rename from packages/@n8n/config/src/configs/external-secrets.ts
rename to packages/@n8n/config/src/configs/external-secrets.config.ts
index a5310d675e..1195adf660 100644
--- a/packages/@n8n/config/src/configs/external-secrets.ts
+++ b/packages/@n8n/config/src/configs/external-secrets.config.ts
@@ -4,9 +4,9 @@ import { Config, Env } from '../decorators';
export class ExternalSecretsConfig {
/** How often (in seconds) to check for secret updates */
@Env('N8N_EXTERNAL_SECRETS_UPDATE_INTERVAL')
- readonly updateInterval: number = 300;
+ updateInterval: number = 300;
/** Whether to prefer GET over LIST when fetching secrets from Hashicorp Vault */
@Env('N8N_EXTERNAL_SECRETS_PREFER_GET')
- readonly preferGet: boolean = false;
+ preferGet: boolean = false;
}
diff --git a/packages/@n8n/config/src/configs/external-storage.ts b/packages/@n8n/config/src/configs/external-storage.config.ts
similarity index 75%
rename from packages/@n8n/config/src/configs/external-storage.ts
rename to packages/@n8n/config/src/configs/external-storage.config.ts
index c876e0ee34..6e5fbd64d8 100644
--- a/packages/@n8n/config/src/configs/external-storage.ts
+++ b/packages/@n8n/config/src/configs/external-storage.config.ts
@@ -4,39 +4,39 @@ import { Config, Env, Nested } from '../decorators';
class S3BucketConfig {
/** Name of the n8n bucket in S3-compatible external storage */
@Env('N8N_EXTERNAL_STORAGE_S3_BUCKET_NAME')
- readonly name: string = '';
+ name: string = '';
/** Region of the n8n bucket in S3-compatible external storage @example "us-east-1" */
@Env('N8N_EXTERNAL_STORAGE_S3_BUCKET_REGION')
- readonly region: string = '';
+ region: string = '';
}
@Config
class S3CredentialsConfig {
/** Access key in S3-compatible external storage */
@Env('N8N_EXTERNAL_STORAGE_S3_ACCESS_KEY')
- readonly accessKey: string = '';
+ accessKey: string = '';
/** Access secret in S3-compatible external storage */
@Env('N8N_EXTERNAL_STORAGE_S3_ACCESS_SECRET')
- readonly accessSecret: string = '';
+ accessSecret: string = '';
}
@Config
class S3Config {
/** Host of the n8n bucket in S3-compatible external storage @example "s3.us-east-1.amazonaws.com" */
@Env('N8N_EXTERNAL_STORAGE_S3_HOST')
- readonly host: string = '';
+ host: string = '';
@Nested
- readonly bucket: S3BucketConfig;
+ bucket: S3BucketConfig;
@Nested
- readonly credentials: S3CredentialsConfig;
+ credentials: S3CredentialsConfig;
}
@Config
export class ExternalStorageConfig {
@Nested
- readonly s3: S3Config;
+ s3: S3Config;
}
diff --git a/packages/@n8n/config/src/configs/nodes.ts b/packages/@n8n/config/src/configs/nodes.config.ts
similarity index 67%
rename from packages/@n8n/config/src/configs/nodes.ts
rename to packages/@n8n/config/src/configs/nodes.config.ts
index f845607a8c..577c4055ab 100644
--- a/packages/@n8n/config/src/configs/nodes.ts
+++ b/packages/@n8n/config/src/configs/nodes.config.ts
@@ -25,22 +25,30 @@ class CommunityPackagesConfig {
/** Whether to enable community packages */
@Env('N8N_COMMUNITY_PACKAGES_ENABLED')
enabled: boolean = true;
+
+ /** NPM registry URL to pull community packages from */
+ @Env('N8N_COMMUNITY_PACKAGES_REGISTRY')
+ registry: string = 'https://registry.npmjs.org';
+
+ /** Whether to reinstall any missing community packages */
+ @Env('N8N_REINSTALL_MISSING_PACKAGES')
+ reinstallMissing: boolean = false;
}
@Config
export class NodesConfig {
/** Node types to load. Includes all if unspecified. @example '["n8n-nodes-base.hackerNews"]' */
@Env('NODES_INCLUDE')
- readonly include: JsonStringArray = [];
+ include: JsonStringArray = [];
/** Node types not to load. Excludes none if unspecified. @example '["n8n-nodes-base.hackerNews"]' */
@Env('NODES_EXCLUDE')
- readonly exclude: JsonStringArray = [];
+ exclude: JsonStringArray = [];
/** Node type to use as error trigger */
@Env('NODES_ERROR_TRIGGER_TYPE')
- readonly errorTriggerType: string = 'n8n-nodes-base.errorTrigger';
+ errorTriggerType: string = 'n8n-nodes-base.errorTrigger';
@Nested
- readonly communityPackages: CommunityPackagesConfig;
+ communityPackages: CommunityPackagesConfig;
}
diff --git a/packages/@n8n/config/src/configs/public-api.ts b/packages/@n8n/config/src/configs/public-api.config.ts
similarity index 74%
rename from packages/@n8n/config/src/configs/public-api.ts
rename to packages/@n8n/config/src/configs/public-api.config.ts
index 33e3bf3fc3..340c54fbd7 100644
--- a/packages/@n8n/config/src/configs/public-api.ts
+++ b/packages/@n8n/config/src/configs/public-api.config.ts
@@ -4,13 +4,13 @@ import { Config, Env } from '../decorators';
export class PublicApiConfig {
/** Whether to disable the Public API */
@Env('N8N_PUBLIC_API_DISABLED')
- readonly disabled: boolean = false;
+ disabled: boolean = false;
/** Path segment for the Public API */
@Env('N8N_PUBLIC_API_ENDPOINT')
- readonly path: string = 'api';
+ path: string = 'api';
/** Whether to disable the Swagger UI for the Public API */
@Env('N8N_PUBLIC_API_SWAGGERUI_DISABLED')
- readonly swaggerUiDisabled: boolean = false;
+ swaggerUiDisabled: boolean = false;
}
diff --git a/packages/@n8n/config/src/configs/scaling-mode.config.ts b/packages/@n8n/config/src/configs/scaling-mode.config.ts
new file mode 100644
index 0000000000..750de77b07
--- /dev/null
+++ b/packages/@n8n/config/src/configs/scaling-mode.config.ts
@@ -0,0 +1,96 @@
+import { Config, Env, Nested } from '../decorators';
+
+@Config
+class HealthConfig {
+ /** Whether to enable the worker health check endpoint `/healthz`. */
+ @Env('QUEUE_HEALTH_CHECK_ACTIVE')
+ active: boolean = false;
+
+ /** Port for worker to respond to health checks requests on, if enabled. */
+ @Env('QUEUE_HEALTH_CHECK_PORT')
+ port: number = 5678;
+}
+
+@Config
+class RedisConfig {
+ /** Redis database for Bull queue. */
+ @Env('QUEUE_BULL_REDIS_DB')
+ db: number = 0;
+
+ /** Redis host for Bull queue. */
+ @Env('QUEUE_BULL_REDIS_HOST')
+ host: string = 'localhost';
+
+ /** Password to authenticate with Redis. */
+ @Env('QUEUE_BULL_REDIS_PASSWORD')
+ password: string = '';
+
+ /** Port for Redis to listen on. */
+ @Env('QUEUE_BULL_REDIS_PORT')
+ port: number = 6379;
+
+ /** Max cumulative timeout (in milliseconds) of connection retries before process exit. */
+ @Env('QUEUE_BULL_REDIS_TIMEOUT_THRESHOLD')
+ timeoutThreshold: number = 10_000;
+
+ /** Redis username. Redis 6.0 or higher required. */
+ @Env('QUEUE_BULL_REDIS_USERNAME')
+ username: string = '';
+
+ /** Redis cluster startup nodes, as comma-separated list of `{host}:{port}` pairs. @example 'redis-1:6379,redis-2:6379' */
+ @Env('QUEUE_BULL_REDIS_CLUSTER_NODES')
+ clusterNodes: string = '';
+
+ /** Whether to enable TLS on Redis connections. */
+ @Env('QUEUE_BULL_REDIS_TLS')
+ tls: boolean = false;
+}
+
+@Config
+class SettingsConfig {
+ /** How long (in milliseconds) is the lease period for a worker processing a job. */
+ @Env('QUEUE_WORKER_LOCK_DURATION')
+ lockDuration: number = 30_000;
+
+ /** How often (in milliseconds) a worker must renew the lease. */
+ @Env('QUEUE_WORKER_LOCK_RENEW_TIME')
+ lockRenewTime: number = 15_000;
+
+ /** How often (in milliseconds) Bull must check for stalled jobs. `0` to disable. */
+ @Env('QUEUE_WORKER_STALLED_INTERVAL')
+ stalledInterval: number = 30_000;
+
+ /** Max number of times a stalled job will be re-processed. See Bull's [documentation](https://docs.bullmq.io/guide/workers/stalled-jobs). */
+ @Env('QUEUE_WORKER_MAX_STALLED_COUNT')
+ maxStalledCount: number = 1;
+}
+
+@Config
+class BullConfig {
+ /** Prefix for Bull keys on Redis. @example 'bull:jobs:23' */
+ @Env('QUEUE_BULL_PREFIX')
+ prefix: string = 'bull';
+
+ @Nested
+ redis: RedisConfig;
+
+ /** How often (in seconds) to poll the Bull queue to identify executions finished during a Redis crash. `0` to disable. May increase Redis traffic significantly. */
+ @Env('QUEUE_RECOVERY_INTERVAL')
+ queueRecoveryInterval: number = 60; // watchdog interval
+
+ /** @deprecated How long (in seconds) a worker must wait for active executions to finish before exiting. Use `N8N_GRACEFUL_SHUTDOWN_TIMEOUT` instead */
+ @Env('QUEUE_WORKER_TIMEOUT')
+ gracefulShutdownTimeout: number = 30;
+
+ @Nested
+ settings: SettingsConfig;
+}
+
+@Config
+export class ScalingModeConfig {
+ @Nested
+ health: HealthConfig;
+
+ @Nested
+ bull: BullConfig;
+}
diff --git a/packages/@n8n/config/src/configs/templates.ts b/packages/@n8n/config/src/configs/templates.config.ts
similarity index 74%
rename from packages/@n8n/config/src/configs/templates.ts
rename to packages/@n8n/config/src/configs/templates.config.ts
index 3e10c892b3..0707330b32 100644
--- a/packages/@n8n/config/src/configs/templates.ts
+++ b/packages/@n8n/config/src/configs/templates.config.ts
@@ -4,9 +4,9 @@ import { Config, Env } from '../decorators';
export class TemplatesConfig {
/** Whether to load workflow templates. */
@Env('N8N_TEMPLATES_ENABLED')
- readonly enabled: boolean = true;
+ enabled: boolean = true;
/** Host to retrieve workflow templates from endpoints. */
@Env('N8N_TEMPLATES_HOST')
- readonly host: string = 'https://api.n8n.io/api/';
+ host: string = 'https://api.n8n.io/api/';
}
diff --git a/packages/@n8n/config/src/configs/email.ts b/packages/@n8n/config/src/configs/user-management.config.ts
similarity index 66%
rename from packages/@n8n/config/src/configs/email.ts
rename to packages/@n8n/config/src/configs/user-management.config.ts
index 318c352380..956bac2b75 100644
--- a/packages/@n8n/config/src/configs/email.ts
+++ b/packages/@n8n/config/src/configs/user-management.config.ts
@@ -1,78 +1,84 @@
import { Config, Env, Nested } from '../decorators';
@Config
-export class SmtpAuth {
+class SmtpAuth {
/** SMTP login username */
@Env('N8N_SMTP_USER')
- readonly user: string = '';
+ user: string = '';
/** SMTP login password */
@Env('N8N_SMTP_PASS')
- readonly pass: string = '';
+ pass: string = '';
/** SMTP OAuth Service Client */
@Env('N8N_SMTP_OAUTH_SERVICE_CLIENT')
- readonly serviceClient: string = '';
+ serviceClient: string = '';
/** SMTP OAuth Private Key */
@Env('N8N_SMTP_OAUTH_PRIVATE_KEY')
- readonly privateKey: string = '';
+ privateKey: string = '';
}
@Config
-export class SmtpConfig {
+class SmtpConfig {
/** SMTP server host */
@Env('N8N_SMTP_HOST')
- readonly host: string = '';
+ host: string = '';
/** SMTP server port */
@Env('N8N_SMTP_PORT')
- readonly port: number = 465;
+ port: number = 465;
/** Whether to use SSL for SMTP */
@Env('N8N_SMTP_SSL')
- readonly secure: boolean = true;
+ secure: boolean = true;
/** Whether to use STARTTLS for SMTP when SSL is disabled */
@Env('N8N_SMTP_STARTTLS')
- readonly startTLS: boolean = true;
+ startTLS: boolean = true;
/** How to display sender name */
@Env('N8N_SMTP_SENDER')
- readonly sender: string = '';
+ sender: string = '';
@Nested
- readonly auth: SmtpAuth;
+ auth: SmtpAuth;
}
@Config
export class TemplateConfig {
/** Overrides default HTML template for inviting new people (use full path) */
@Env('N8N_UM_EMAIL_TEMPLATES_INVITE')
- readonly invite: string = '';
+ invite: string = '';
/** Overrides default HTML template for resetting password (use full path) */
@Env('N8N_UM_EMAIL_TEMPLATES_PWRESET')
- readonly passwordReset: string = '';
+ passwordReset: string = '';
/** Overrides default HTML template for notifying that a workflow was shared (use full path) */
@Env('N8N_UM_EMAIL_TEMPLATES_WORKFLOW_SHARED')
- readonly workflowShared: string = '';
+ workflowShared: string = '';
/** Overrides default HTML template for notifying that credentials were shared (use full path) */
@Env('N8N_UM_EMAIL_TEMPLATES_CREDENTIALS_SHARED')
- readonly credentialsShared: string = '';
+ credentialsShared: string = '';
}
@Config
-export class EmailConfig {
+class EmailConfig {
/** How to send emails */
@Env('N8N_EMAIL_MODE')
- readonly mode: '' | 'smtp' = 'smtp';
+ mode: '' | 'smtp' = 'smtp';
@Nested
- readonly smtp: SmtpConfig;
+ smtp: SmtpConfig;
@Nested
- readonly template: TemplateConfig;
+ template: TemplateConfig;
+}
+
+@Config
+export class UserManagementConfig {
+ @Nested
+ emails: EmailConfig;
}
diff --git a/packages/@n8n/config/src/configs/version-notifications.ts b/packages/@n8n/config/src/configs/version-notifications.config.ts
similarity index 70%
rename from packages/@n8n/config/src/configs/version-notifications.ts
rename to packages/@n8n/config/src/configs/version-notifications.config.ts
index 1aa693228d..313e264a2d 100644
--- a/packages/@n8n/config/src/configs/version-notifications.ts
+++ b/packages/@n8n/config/src/configs/version-notifications.config.ts
@@ -4,13 +4,13 @@ import { Config, Env } from '../decorators';
export class VersionNotificationsConfig {
/** Whether to request notifications about new n8n versions */
@Env('N8N_VERSION_NOTIFICATIONS_ENABLED')
- readonly enabled: boolean = true;
+ enabled: boolean = true;
/** Endpoint to retrieve n8n version information from */
@Env('N8N_VERSION_NOTIFICATIONS_ENDPOINT')
- readonly endpoint: string = 'https://api.n8n.io/api/versions/';
+ endpoint: string = 'https://api.n8n.io/api/versions/';
/** URL for versions panel to page instructing user on how to update n8n instance */
@Env('N8N_VERSION_NOTIFICATIONS_INFO_URL')
- readonly infoUrl: string = 'https://docs.n8n.io/hosting/installation/updating/';
+ infoUrl: string = 'https://docs.n8n.io/hosting/installation/updating/';
}
diff --git a/packages/@n8n/config/src/configs/workflows.ts b/packages/@n8n/config/src/configs/workflows.config.ts
similarity index 60%
rename from packages/@n8n/config/src/configs/workflows.ts
rename to packages/@n8n/config/src/configs/workflows.config.ts
index b19f4bc95d..3d6eaad12f 100644
--- a/packages/@n8n/config/src/configs/workflows.ts
+++ b/packages/@n8n/config/src/configs/workflows.config.ts
@@ -4,17 +4,14 @@ import { Config, Env } from '../decorators';
export class WorkflowsConfig {
/** Default name for workflow */
@Env('WORKFLOWS_DEFAULT_NAME')
- readonly defaultName: string = 'My workflow';
+ defaultName: string = 'My workflow';
/** Show onboarding flow in new workflow */
@Env('N8N_ONBOARDING_FLOW_DISABLED')
- readonly onboardingFlowDisabled: boolean = false;
+ onboardingFlowDisabled: boolean = false;
/** Default option for which workflows may call the current workflow */
@Env('N8N_WORKFLOW_CALLER_POLICY_DEFAULT_OPTION')
- readonly callerPolicyDefaultOption:
- | 'any'
- | 'none'
- | 'workflowsFromAList'
- | 'workflowsFromSameOwner' = 'workflowsFromSameOwner';
+ callerPolicyDefaultOption: 'any' | 'none' | 'workflowsFromAList' | 'workflowsFromSameOwner' =
+ 'workflowsFromSameOwner';
}
diff --git a/packages/@n8n/config/src/decorators.ts b/packages/@n8n/config/src/decorators.ts
index fd68d44085..c5549a674f 100644
--- a/packages/@n8n/config/src/decorators.ts
+++ b/packages/@n8n/config/src/decorators.ts
@@ -47,6 +47,11 @@ export const Config: ClassDecorator = (ConfigClass: Class) => {
} else {
value = value === 'true';
}
+ } else if (type === Object) {
+ // eslint-disable-next-line n8n-local-rules/no-plain-errors
+ throw new Error(
+ `Invalid decorator metadata on key "${key as string}" on ${ConfigClass.name}\n Please use explicit typing on all config fields`,
+ );
} else if (type !== String && type !== Object) {
value = new (type as Constructable)(value as string);
}
diff --git a/packages/@n8n/config/src/index.ts b/packages/@n8n/config/src/index.ts
index b32c7c7dc3..fd1dc50c18 100644
--- a/packages/@n8n/config/src/index.ts
+++ b/packages/@n8n/config/src/index.ts
@@ -1,54 +1,80 @@
-import { Config, Nested } from './decorators';
-import { CredentialsConfig } from './configs/credentials';
-import { DatabaseConfig } from './configs/database';
-import { EmailConfig } from './configs/email';
-import { VersionNotificationsConfig } from './configs/version-notifications';
-import { PublicApiConfig } from './configs/public-api';
-import { ExternalSecretsConfig } from './configs/external-secrets';
-import { TemplatesConfig } from './configs/templates';
-import { EventBusConfig } from './configs/event-bus';
-import { NodesConfig } from './configs/nodes';
-import { ExternalStorageConfig } from './configs/external-storage';
-import { WorkflowsConfig } from './configs/workflows';
-
-@Config
-class UserManagementConfig {
- @Nested
- emails: EmailConfig;
-}
+import { Config, Env, Nested } from './decorators';
+import { CredentialsConfig } from './configs/credentials.config';
+import { DatabaseConfig } from './configs/database.config';
+import { VersionNotificationsConfig } from './configs/version-notifications.config';
+import { PublicApiConfig } from './configs/public-api.config';
+import { ExternalSecretsConfig } from './configs/external-secrets.config';
+import { TemplatesConfig } from './configs/templates.config';
+import { EventBusConfig } from './configs/event-bus.config';
+import { NodesConfig } from './configs/nodes.config';
+import { ExternalStorageConfig } from './configs/external-storage.config';
+import { WorkflowsConfig } from './configs/workflows.config';
+import { EndpointsConfig } from './configs/endpoints.config';
+import { CacheConfig } from './configs/cache.config';
+import { ScalingModeConfig } from './configs/scaling-mode.config';
+import { UserManagementConfig } from './configs/user-management.config';
@Config
export class GlobalConfig {
@Nested
- readonly database: DatabaseConfig;
+ database: DatabaseConfig;
@Nested
- readonly credentials: CredentialsConfig;
+ credentials: CredentialsConfig;
@Nested
- readonly userManagement: UserManagementConfig;
+ userManagement: UserManagementConfig;
@Nested
- readonly versionNotifications: VersionNotificationsConfig;
+ versionNotifications: VersionNotificationsConfig;
@Nested
- readonly publicApi: PublicApiConfig;
+ publicApi: PublicApiConfig;
@Nested
- readonly externalSecrets: ExternalSecretsConfig;
+ externalSecrets: ExternalSecretsConfig;
@Nested
- readonly templates: TemplatesConfig;
+ templates: TemplatesConfig;
@Nested
- readonly eventBus: EventBusConfig;
+ eventBus: EventBusConfig;
@Nested
- readonly nodes: NodesConfig;
+ nodes: NodesConfig;
@Nested
- readonly externalStorage: ExternalStorageConfig;
+ externalStorage: ExternalStorageConfig;
@Nested
- readonly workflows: WorkflowsConfig;
+ workflows: WorkflowsConfig;
+
+ /** Path n8n is deployed to */
+ @Env('N8N_PATH')
+ path: string = '/';
+
+ /** Host name n8n can be reached */
+ @Env('N8N_HOST')
+ host: string = 'localhost';
+
+ /** HTTP port n8n can be reached */
+ @Env('N8N_PORT')
+ port: number = 5678;
+
+ /** IP address n8n should listen on */
+ @Env('N8N_LISTEN_ADDRESS')
+ listen_address: string = '0.0.0.0';
+
+ /** HTTP Protocol via which n8n can be reached */
+ @Env('N8N_PROTOCOL')
+ protocol: 'http' | 'https' = 'http';
+
+ @Nested
+ endpoints: EndpointsConfig;
+
+ @Nested
+ cache: CacheConfig;
+
+ @Nested
+ queue: ScalingModeConfig;
}
diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts
index 90c9ee3240..adecff7f9d 100644
--- a/packages/@n8n/config/test/config.test.ts
+++ b/packages/@n8n/config/test/config.test.ts
@@ -18,6 +18,11 @@ describe('GlobalConfig', () => {
});
const defaultConfig: GlobalConfig = {
+ path: '/',
+ host: 'localhost',
+ port: 5678,
+ listen_address: '0.0.0.0',
+ protocol: 'http',
database: {
logging: {
enabled: false,
@@ -103,6 +108,8 @@ describe('GlobalConfig', () => {
nodes: {
communityPackages: {
enabled: true,
+ registry: 'https://registry.npmjs.org',
+ reinstallMissing: false,
},
errorTriggerType: 'n8n-nodes-base.errorTrigger',
include: [],
@@ -140,12 +147,82 @@ describe('GlobalConfig', () => {
onboardingFlowDisabled: false,
callerPolicyDefaultOption: 'workflowsFromSameOwner',
},
+ endpoints: {
+ metrics: {
+ enable: false,
+ prefix: 'n8n_',
+ includeWorkflowIdLabel: false,
+ includeDefaultMetrics: true,
+ includeMessageEventBusMetrics: false,
+ includeNodeTypeLabel: false,
+ includeCacheMetrics: false,
+ includeApiEndpoints: false,
+ includeApiPathLabel: false,
+ includeApiMethodLabel: false,
+ includeCredentialTypeLabel: false,
+ includeApiStatusCodeLabel: false,
+ },
+ additionalNonUIRoutes: '',
+ disableProductionWebhooksOnMainProcess: false,
+ disableUi: false,
+ form: 'form',
+ formTest: 'form-test',
+ formWaiting: 'form-waiting',
+ payloadSizeMax: 16,
+ rest: 'rest',
+ webhook: 'webhook',
+ webhookTest: 'webhook-test',
+ webhookWaiting: 'webhook-waiting',
+ },
+ cache: {
+ backend: 'auto',
+ memory: {
+ maxSize: 3145728,
+ ttl: 3600000,
+ },
+ redis: {
+ prefix: 'redis',
+ ttl: 3600000,
+ },
+ },
+ queue: {
+ health: {
+ active: false,
+ port: 5678,
+ },
+ bull: {
+ redis: {
+ db: 0,
+ host: 'localhost',
+ password: '',
+ port: 6379,
+ timeoutThreshold: 10_000,
+ username: '',
+ clusterNodes: '',
+ tls: false,
+ },
+ queueRecoveryInterval: 60,
+ gracefulShutdownTimeout: 30,
+ prefix: 'bull',
+ settings: {
+ lockDuration: 30_000,
+ lockRenewTime: 15_000,
+ stalledInterval: 30_000,
+ maxStalledCount: 1,
+ },
+ },
+ },
};
it('should use all default values when no env variables are defined', () => {
process.env = {};
const config = Container.get(GlobalConfig);
- expect(config).toEqual(defaultConfig);
+
+ // deepCopy for diff to show plain objects
+ // eslint-disable-next-line n8n-local-rules/no-json-parse-json-stringify
+ const deepCopy = (obj: T): T => JSON.parse(JSON.stringify(obj));
+
+ expect(deepCopy(config)).toEqual(defaultConfig);
expect(mockFs.readFileSync).not.toHaveBeenCalled();
});
@@ -155,6 +232,7 @@ describe('GlobalConfig', () => {
DB_POSTGRESDB_USER: 'n8n',
DB_TABLE_PREFIX: 'test_',
NODES_INCLUDE: '["n8n-nodes-base.hackerNews"]',
+ DB_LOGGING_MAX_EXECUTION_TIME: '0',
};
const config = Container.get(GlobalConfig);
expect(config).toEqual({
diff --git a/packages/@n8n/nodes-langchain/README.md b/packages/@n8n/nodes-langchain/README.md
index e5761628cf..03a23d2186 100644
--- a/packages/@n8n/nodes-langchain/README.md
+++ b/packages/@n8n/nodes-langchain/README.md
@@ -8,6 +8,4 @@ These nodes are still in Beta state and are only compatible with the Docker imag
## License
-n8n is [fair-code](https://faircode.io) distributed under the [**Sustainable Use License**](https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md).
-
-Additional information about the license can be found in the [docs](https://docs.n8n.io/reference/license/).
+You can find the license information [here](https://github.com/n8n-io/n8n/blob/master/README.md#license)
diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts
index 4a6d8a733c..62761742b0 100644
--- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts
+++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts
@@ -9,7 +9,6 @@ import type {
INodeTypeDescription,
INodeProperties,
} from 'n8n-workflow';
-import { getTemplateNoticeField } from '../../../utils/sharedFields';
import { promptTypeOptions, textInput } from '../../../utils/descriptions';
import { conversationalAgentProperties } from './agents/ConversationalAgent/description';
import { conversationalAgentExecute } from './agents/ConversationalAgent/execute';
@@ -83,6 +82,7 @@ function getInputs(
filter: {
nodes: [
'@n8n/n8n-nodes-langchain.lmChatAnthropic',
+ '@n8n/n8n-nodes-langchain.lmChatAwsBedrock',
'@n8n/n8n-nodes-langchain.lmChatGroq',
'@n8n/n8n-nodes-langchain.lmChatOllama',
'@n8n/n8n-nodes-langchain.lmChatOpenAi',
@@ -113,6 +113,7 @@ function getInputs(
'@n8n/n8n-nodes-langchain.lmChatAnthropic',
'@n8n/n8n-nodes-langchain.lmChatAzureOpenAi',
'@n8n/n8n-nodes-langchain.lmChatMistralCloud',
+ '@n8n/n8n-nodes-langchain.lmChatOllama',
'@n8n/n8n-nodes-langchain.lmChatOpenAi',
'@n8n/n8n-nodes-langchain.lmChatGroq',
'@n8n/n8n-nodes-langchain.lmChatGoogleVertex',
@@ -206,35 +207,37 @@ const agentTypeProperty: INodeProperties = {
name: 'Tools Agent',
value: 'toolsAgent',
description:
- 'Utilized unified Tool calling interface to select the appropriate tools and argument for execution',
+ 'Utilizes structured tool schemas for precise and reliable tool selection and execution. Recommended for complex tasks requiring accurate and consistent tool usage, but only usable with models that support tool calling.',
},
{
name: 'Conversational Agent',
value: 'conversationalAgent',
description:
- 'Selects tools to accomplish its task and uses memory to recall previous conversations',
+ 'Describes tools in the system prompt and parses JSON responses for tool calls. More flexible but potentially less reliable than the Tools Agent. Suitable for simpler interactions or with models not supporting structured schemas.',
},
{
name: 'OpenAI Functions Agent',
value: 'openAiFunctionsAgent',
description:
- "Utilizes OpenAI's Function Calling feature to select the appropriate tool and arguments for execution",
+ "Leverages OpenAI's function calling capabilities to precisely select and execute tools. Excellent for tasks requiring structured outputs when working with OpenAI models.",
},
{
name: 'Plan and Execute Agent',
value: 'planAndExecuteAgent',
description:
- 'Plan and execute agents accomplish an objective by first planning what to do, then executing the sub tasks',
+ 'Creates a high-level plan for complex tasks and then executes each step. Suitable for multi-stage problems or when a strategic approach is needed.',
},
{
name: 'ReAct Agent',
value: 'reActAgent',
- description: 'Strategically select tools to accomplish a given task',
+ description:
+ 'Combines reasoning and action in an iterative process. Effective for tasks that require careful analysis and step-by-step problem-solving.',
},
{
name: 'SQL Agent',
value: 'sqlAgent',
- description: 'Answers questions about data in an SQL database',
+ description:
+ 'Specializes in interacting with SQL databases. Ideal for data analysis tasks, generating queries, or extracting insights from structured data.',
},
],
default: '',
@@ -256,7 +259,7 @@ export class Agent implements INodeType {
color: '#404040',
},
codex: {
- alias: ['LangChain'],
+ alias: ['LangChain', 'Chat', 'Conversational', 'Plan and Execute', 'ReAct', 'Tools'],
categories: ['AI'],
subcategories: {
AI: ['Agents', 'Root Nodes'],
@@ -302,10 +305,14 @@ export class Agent implements INodeType {
],
properties: [
{
- ...getTemplateNoticeField(1954),
+ displayName:
+ 'Tip: Get a feel for agents with our quick tutorial or see an example of how this node works',
+ name: 'notice_tip',
+ type: 'notice',
+ default: '',
displayOptions: {
show: {
- agent: ['conversationalAgent'],
+ agent: ['conversationalAgent', 'toolsAgent'],
},
},
},
diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts
index a027829e3a..3c4ff28f06 100644
--- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts
+++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/OpenAiFunctionsAgent/execute.ts
@@ -38,7 +38,7 @@ export async function openAiFunctionsAgentExecute(
const memory = (await this.getInputConnectionData(NodeConnectionType.AiMemory, 0)) as
| BaseChatMemory
| undefined;
- const tools = await getConnectedTools(this, nodeVersion >= 1.5);
+ const tools = await getConnectedTools(this, nodeVersion >= 1.5, false);
const outputParsers = await getOptionalOutputParsers(this);
const options = this.getNodeParameter('options', 0, {}) as {
systemMessage?: string;
diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts
index 81ac6bad5d..e0da7f1e31 100644
--- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts
+++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/execute.ts
@@ -90,7 +90,7 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise;
+ const tools = (await getConnectedTools(this, true, false)) as Array;
const outputParser = (await getOptionalOutputParsers(this))?.[0];
let structuredOutputParserTool: DynamicStructuredTool | undefined;
diff --git a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts
index f56ce7c5c4..8f05faccb0 100644
--- a/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts
+++ b/packages/@n8n/nodes-langchain/nodes/agents/OpenAiAssistant/OpenAiAssistant.node.ts
@@ -313,7 +313,7 @@ export class OpenAiAssistant implements INodeType {
async execute(this: IExecuteFunctions): Promise {
const nodeVersion = this.getNode().typeVersion;
- const tools = await getConnectedTools(this, nodeVersion > 1);
+ const tools = await getConnectedTools(this, nodeVersion > 1, false);
const credentials = await this.getCredentials('openAiApi');
const items = this.getInputData();
diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/ChainLlm.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/ChainLlm.node.ts
index 35b83dead8..003c8eb7dd 100644
--- a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/ChainLlm.node.ts
+++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/ChainLlm.node.ts
@@ -22,7 +22,7 @@ import { LLMChain } from 'langchain/chains';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { HumanMessage } from '@langchain/core/messages';
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
-import { ChatOllama } from '@langchain/community/chat_models/ollama';
+import { ChatOllama } from '@langchain/ollama';
import { getTemplateNoticeField } from '../../../utils/sharedFields';
import {
getOptionalOutputParsers,
diff --git a/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/InformationExtractor.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/InformationExtractor.node.ts
new file mode 100644
index 0000000000..bdf5163d86
--- /dev/null
+++ b/packages/@n8n/nodes-langchain/nodes/chains/InformationExtractor/InformationExtractor.node.ts
@@ -0,0 +1,308 @@
+import { jsonParse, NodeConnectionType, NodeOperationError } from 'n8n-workflow';
+import type {
+ INodeType,
+ INodeTypeDescription,
+ IExecuteFunctions,
+ INodeExecutionData,
+ INodePropertyOptions,
+} from 'n8n-workflow';
+import type { JSONSchema7 } from 'json-schema';
+import type { BaseLanguageModel } from '@langchain/core/language_models/base';
+import { ChatPromptTemplate, SystemMessagePromptTemplate } from '@langchain/core/prompts';
+import type { z } from 'zod';
+import { OutputFixingParser, StructuredOutputParser } from 'langchain/output_parsers';
+import { HumanMessage } from '@langchain/core/messages';
+import { generateSchema, getSandboxWithZod } from '../../../utils/schemaParsing';
+import {
+ inputSchemaField,
+ jsonSchemaExampleField,
+ schemaTypeField,
+} from '../../../utils/descriptions';
+import { getTracingConfig } from '../../../utils/tracing';
+import type { AttributeDefinition } from './types';
+import { makeZodSchemaFromAttributes } from './helpers';
+
+const SYSTEM_PROMPT_TEMPLATE = `You are an expert extraction algorithm.
+Only extract relevant information from the text.
+If you do not know the value of an attribute asked to extract, you may omit the attribute's value.`;
+
+export class InformationExtractor implements INodeType {
+ description: INodeTypeDescription = {
+ displayName: 'Information Extractor',
+ name: 'informationExtractor',
+ icon: 'fa:project-diagram',
+ iconColor: 'black',
+ group: ['transform'],
+ version: 1,
+ description: 'Extract information from text in a structured format',
+ codex: {
+ alias: ['NER', 'parse', 'parsing', 'JSON', 'data extraction', 'structured'],
+ categories: ['AI'],
+ subcategories: {
+ AI: ['Chains', 'Root Nodes'],
+ },
+ resources: {
+ primaryDocumentation: [
+ {
+ url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.information-extractor/',
+ },
+ ],
+ },
+ },
+ defaults: {
+ name: 'Information Extractor',
+ },
+ inputs: [
+ { displayName: '', type: NodeConnectionType.Main },
+ {
+ displayName: 'Model',
+ maxConnections: 1,
+ type: NodeConnectionType.AiLanguageModel,
+ required: true,
+ },
+ ],
+ outputs: [NodeConnectionType.Main],
+ properties: [
+ {
+ displayName: 'Text',
+ name: 'text',
+ type: 'string',
+ default: '',
+ description: 'The text to extract information from',
+ typeOptions: {
+ rows: 2,
+ },
+ },
+ {
+ ...schemaTypeField,
+ description: 'How to specify the schema for the desired output',
+ options: [
+ {
+ name: 'From Attribute Descriptions',
+ value: 'fromAttributes',
+ description:
+ 'Extract specific attributes from the text based on types and descriptions',
+ } as INodePropertyOptions,
+ ...(schemaTypeField.options as INodePropertyOptions[]),
+ ],
+ default: 'fromAttributes',
+ },
+ {
+ ...jsonSchemaExampleField,
+ default: `{
+ "state": "California",
+ "cities": ["Los Angeles", "San Francisco", "San Diego"]
+}`,
+ },
+ {
+ ...inputSchemaField,
+ default: `{
+ "type": "object",
+ "properties": {
+ "state": {
+ "type": "string"
+ },
+ "cities": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+}`,
+ },
+ {
+ displayName:
+ 'The schema has to be defined in the JSON Schema format. Look at this page for examples.',
+ name: 'notice',
+ type: 'notice',
+ default: '',
+ displayOptions: {
+ show: {
+ schemaType: ['manual'],
+ },
+ },
+ },
+ {
+ displayName: 'Attributes',
+ name: 'attributes',
+ placeholder: 'Add Attribute',
+ type: 'fixedCollection',
+ default: {},
+ displayOptions: {
+ show: {
+ schemaType: ['fromAttributes'],
+ },
+ },
+ typeOptions: {
+ multipleValues: true,
+ },
+ options: [
+ {
+ name: 'attributes',
+ displayName: 'Attribute List',
+ values: [
+ {
+ displayName: 'Name',
+ name: 'name',
+ type: 'string',
+ default: '',
+ description: 'Attribute to extract',
+ placeholder: 'e.g. company_name',
+ required: true,
+ },
+ {
+ displayName: 'Type',
+ name: 'type',
+ type: 'options',
+ description: 'Data type of the attribute',
+ required: true,
+ options: [
+ {
+ name: 'Boolean',
+ value: 'boolean',
+ },
+ {
+ name: 'Date',
+ value: 'date',
+ },
+ {
+ name: 'Number',
+ value: 'number',
+ },
+ {
+ name: 'String',
+ value: 'string',
+ },
+ ],
+ default: 'string',
+ },
+ {
+ displayName: 'Description',
+ name: 'description',
+ type: 'string',
+ default: '',
+ description: 'Describe your attribute',
+ placeholder: 'Add description for the attribute',
+ required: true,
+ },
+ {
+ displayName: 'Required',
+ name: 'required',
+ type: 'boolean',
+ default: false,
+ description: 'Whether attribute is required',
+ required: true,
+ },
+ ],
+ },
+ ],
+ },
+ {
+ displayName: 'Options',
+ name: 'options',
+ type: 'collection',
+ default: {},
+ placeholder: 'Add Option',
+ options: [
+ {
+ displayName: 'System Prompt Template',
+ name: 'systemPromptTemplate',
+ type: 'string',
+ default: SYSTEM_PROMPT_TEMPLATE,
+ description: 'String to use directly as the system prompt template',
+ typeOptions: {
+ rows: 6,
+ },
+ },
+ ],
+ },
+ ],
+ };
+
+ async execute(this: IExecuteFunctions): Promise {
+ const items = this.getInputData();
+
+ const llm = (await this.getInputConnectionData(
+ NodeConnectionType.AiLanguageModel,
+ 0,
+ )) as BaseLanguageModel;
+
+ const schemaType = this.getNodeParameter('schemaType', 0, '') as
+ | 'fromAttributes'
+ | 'fromJson'
+ | 'manual';
+
+ let parser: OutputFixingParser