Merge branch 'master' of github.com:n8n-io/n8n into feat-Add-Sysdig-credentials

This commit is contained in:
Jonathan Bennetts 2024-08-22 11:04:22 +01:00
commit ef213beffb
No known key found for this signature in database
1631 changed files with 33627 additions and 13745 deletions

View file

@ -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": "*"

View file

@ -7,8 +7,7 @@ on:
- edited
- synchronize
branches:
- '**'
- '!release/*'
- 'master'
jobs:
check-pr-title:

View file

@ -4,7 +4,7 @@ on:
workflow_dispatch:
pull_request_review:
types: [submitted]
branch:
branches:
- 'master'
paths:
- packages/design-system/**

View file

@ -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 }}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 }}

View file

@ -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

View file

@ -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 }}

View file

@ -21,7 +21,6 @@ jobs:
steps:
- uses: actions/checkout@v4.1.1
with:
repository: n8n-io/n8n
ref: ${{ inputs.ref }}
- run: corepack enable

View file

@ -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'

View file

@ -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}}

View file

@ -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()

View file

@ -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

View file

@ -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 CVE202337466 ([#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)

View file

@ -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)

View file

@ -0,0 +1,3 @@
export function getSaveChangesModal() {
return cy.get('.el-overlay').contains('Save changes before leaving?');
}

View file

@ -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();
}
}

View file

@ -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';

View file

@ -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');

View file

@ -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);

View file

@ -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');
});
});

View file

@ -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);

View file

@ -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');
});
});

View file

@ -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);
});
});

View file

@ -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');

View file

@ -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');
});

View file

@ -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 = () => {

View file

@ -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');
});
});

View file

@ -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);
});
});

View file

@ -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();
});
});

View file

@ -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');
});
});
});

View file

@ -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');
});
});

View file

@ -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 <br> 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');
});
});

View file

@ -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();"
}
}
}

View file

@ -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"
}
]
}
]
}

View file

@ -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"
}
]
}

View file

@ -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"
}
]
}
]
}

View file

@ -0,0 +1,10 @@
{
"sessionId": "1",
"messages": [
{
"role": "assistant",
"type": "message",
"text": "Hey, this is an assistant message"
}
]
}

View file

@ -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": {}
}

View file

@ -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"
}
}

View file

@ -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);
},
};
}

View file

@ -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 });

View file

@ -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();
}

View file

@ -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) => {

View file

@ -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<DragAndDropOptions>,
): void;
push(type: string, data: unknown): void;
shouldNotHaveConsoleErrors(): void;
window(): Chainable<

View file

@ -3,7 +3,7 @@ export function getPopper() {
}
export function getVisiblePopper() {
return getPopper().filter(':visible');
return getPopper().filter('[aria-hidden="false"]');
}
export function getVisibleSelect() {

View file

@ -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)

View file

@ -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",

View file

@ -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" ]

View file

@ -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/).

View file

@ -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 });
})();

View file

@ -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": " "
}
}

View file

@ -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
}

View file

@ -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": []
}

View file

@ -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"
}

View file

@ -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,
});
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View file

@ -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<typeof loadConfig>;
export function loadConfig() {
const config = convict(configSchema);
config.validate({ allowed: 'strict' });
return config;
}

View file

@ -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<T>(endpoint: string) {
return await this.authenticatedRequest<T>(endpoint, {
method: 'GET',
});
}
async post<T>(endpoint: string, data: unknown) {
return await this.authenticatedRequest<T>(endpoint, {
method: 'POST',
data,
});
}
async patch<T>(endpoint: string, data: unknown) {
return await this.authenticatedRequest<T>(endpoint, {
method: 'PATCH',
data,
});
}
async delete<T>(endpoint: string) {
return await this.authenticatedRequest<T>(endpoint, {
method: 'DELETE',
});
}
protected async authenticatedRequest<T>(endpoint: string, init: Omit<AxiosRequestConfig, 'url'>) {
return await this.restApiRequest<T>(endpoint, {
...init,
headers: {
...init.headers,
cookie: this.authCookie,
},
});
}
}

View file

@ -0,0 +1,78 @@
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
export class N8nApiClient {
constructor(public readonly apiBaseUrl: string) {}
async waitForInstanceToBecomeOnline(): Promise<void> {
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<T>(endpoint: string, init: Omit<AxiosRequestConfig, 'url'>) {
try {
return await axios.request<T>({
...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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View file

@ -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[];
};

View file

@ -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<Workflow[]> {
const response = await this.apiClient.get<{ count: number; data: Workflow[] }>('/workflows');
return response.data.data;
}
async createWorkflow(workflow: unknown): Promise<Workflow> {
const response = await this.apiClient.post<{ data: Workflow }>('/workflows', workflow);
return response.data.data;
}
async activateWorkflow(workflow: Workflow): Promise<Workflow> {
const response = await this.apiClient.patch<{ data: Workflow }>(`/workflows/${workflow.id}`, {
...workflow,
active: true,
});
return response.data.data;
}
async deleteWorkflow(workflowId: Workflow['id']): Promise<void> {
await this.apiClient.delete(`/workflows/${workflowId}`);
}
}

View file

@ -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}`,
);
}
}
}

View file

@ -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');
}
}

View file

@ -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());
}
}
}

View file

@ -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}`;
}
}

View file

@ -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);
}
}

View file

@ -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;
};

View file

@ -0,0 +1,9 @@
{
"extends": ["./tsconfig.json", "../../../tsconfig.build.json"],
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/build.tsbuildinfo"
},
"include": ["src/**/*.ts"]
}

View file

@ -0,0 +1,11 @@
{
"extends": ["../../../tsconfig.json", "../../../tsconfig.backend.json"],
"compilerOptions": {
"rootDir": ".",
"baseUrl": "src",
"paths": {
"@/*": ["./*"]
}
},
"include": ["src/**/*.ts"]
}

View file

@ -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)

View file

@ -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",

View file

@ -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(

View file

@ -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:"
}
}

View file

@ -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',
},
},
],
};

View file

@ -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:"
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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 = '';
}

View file

@ -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';
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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/';
}

View file

@ -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;
}

View file

@ -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/';
}

View file

@ -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';
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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 = <T>(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({

View file

@ -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)

View file

@ -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 <a href="https://docs.n8n.io/advanced-ai/intro-tutorial/" target="_blank">tutorial</a> or see an <a href="/templates/1954" target="_blank">example</a> of how this node works',
name: 'notice_tip',
type: 'notice',
default: '',
displayOptions: {
show: {
agent: ['conversationalAgent'],
agent: ['conversationalAgent', 'toolsAgent'],
},
},
},

View file

@ -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;

View file

@ -90,7 +90,7 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeE
| BaseChatMemory
| undefined;
const tools = (await getConnectedTools(this, true)) as Array<DynamicStructuredTool | Tool>;
const tools = (await getConnectedTools(this, true, false)) as Array<DynamicStructuredTool | Tool>;
const outputParser = (await getOptionalOutputParsers(this))?.[0];
let structuredOutputParserTool: DynamicStructuredTool | undefined;

Some files were not shown because too many files have changed in this diff Show more