mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge branch 'n8n-io:master' into master
This commit is contained in:
commit
65c2bf6d42
3
.github/workflows/check-pr-title.yml
vendored
3
.github/workflows/check-pr-title.yml
vendored
|
@ -7,8 +7,7 @@ on:
|
|||
- edited
|
||||
- synchronize
|
||||
branches:
|
||||
- '**'
|
||||
- '!release/*'
|
||||
- 'master'
|
||||
|
||||
jobs:
|
||||
check-pr-title:
|
||||
|
|
2
.github/workflows/chromatic.yml
vendored
2
.github/workflows/chromatic.yml
vendored
|
@ -4,7 +4,7 @@ on:
|
|||
workflow_dispatch:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
branch:
|
||||
branches:
|
||||
- 'master'
|
||||
paths:
|
||||
- packages/design-system/**
|
||||
|
|
4
.github/workflows/ci-postgres-mysql.yml
vendored
4
.github/workflows/ci-postgres-mysql.yml
vendored
|
@ -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 }}
|
||||
|
|
7
.github/workflows/ci-pull-requests.yml
vendored
7
.github/workflows/ci-pull-requests.yml
vendored
|
@ -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
|
||||
|
|
43
.github/workflows/docker-images-benchmark.yml
vendored
Normal file
43
.github/workflows/docker-images-benchmark.yml
vendored
Normal 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
|
5
.github/workflows/docker-images-nightly.yml
vendored
5
.github/workflows/docker-images-nightly.yml
vendored
|
@ -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
|
||||
|
|
49
.github/workflows/docker-images.yml
vendored
49
.github/workflows/docker-images.yml
vendored
|
@ -1,49 +0,0 @@
|
|||
name: Docker Image CI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'n8n@*'
|
||||
|
||||
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 }}
|
11
.github/workflows/e2e-reusable.yml
vendored
11
.github/workflows/e2e-reusable.yml
vendored
|
@ -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
|
||||
|
|
4
.github/workflows/e2e-tests-pr.yml
vendored
4
.github/workflows/e2e-tests-pr.yml
vendored
|
@ -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 }}
|
||||
|
||||
|
|
1
.github/workflows/linting-reusable.yml
vendored
1
.github/workflows/linting-reusable.yml
vendored
|
@ -21,7 +21,6 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4.1.1
|
||||
with:
|
||||
repository: n8n-io/n8n
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- run: corepack enable
|
||||
|
|
6
.github/workflows/release-create-pr.yml
vendored
6
.github/workflows/release-create-pr.yml
vendored
|
@ -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'
|
||||
|
|
111
.github/workflows/release-publish.yml
vendored
111
.github/workflows/release-publish.yml
vendored
|
@ -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}}
|
||||
|
|
1
.github/workflows/units-tests-reusable.yml
vendored
1
.github/workflows/units-tests-reusable.yml
vendored
|
@ -36,7 +36,6 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4.1.1
|
||||
with:
|
||||
repository: n8n-io/n8n
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- run: corepack enable
|
||||
|
|
42
CHANGELOG.md
42
CHANGELOG.md
|
@ -1,3 +1,45 @@
|
|||
# [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)
|
||||
|
||||
|
||||
|
|
|
@ -61,6 +61,7 @@ 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';
|
||||
export const EXECUTE_WORKFLOW_NODE_NAME = 'Execute Workflow';
|
||||
|
||||
export const META_KEY = Cypress.platform === 'darwin' ? 'meta' : 'ctrl';
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -40,6 +40,8 @@ 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
|
||||
|
@ -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
|
||||
|
@ -146,6 +149,7 @@ describe('Data mapping', () => {
|
|||
ndv.getters
|
||||
.inlineExpressionEditorInput()
|
||||
.should('have.text', '{{ $json.input }}{{ $json.input[0].count }}');
|
||||
ndv.getters.inlineExpressionEditorInput().type('{esc}');
|
||||
ndv.actions.validateExpressionPreview('value', '[object Object]0');
|
||||
});
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
|
@ -350,19 +356,23 @@ describe('Data mapping', () => {
|
|||
workflowPage.actions.zoomToFit();
|
||||
|
||||
workflowPage.actions.openNode('Set');
|
||||
ndv.actions.clearParameterInput('value');
|
||||
ndv.actions.typeIntoParameterInput('value', '=');
|
||||
ndv.actions.typeIntoParameterInput('value', 'hello world{enter}{enter}newline');
|
||||
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', 'bottom');
|
||||
ndv.actions.mapToParameter('value', 'center');
|
||||
|
||||
ndv.getters
|
||||
.inlineExpressionEditorInput()
|
||||
.should('have.text', '{{ $json.input[0].count }}hello worldnewline{{ $json.input }}');
|
||||
.should('have.text', '{{ $json.input[0].count }}hello world{{ $json.input }}newline');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -65,7 +65,7 @@ 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();
|
||||
|
@ -79,7 +79,7 @@ describe('Workflow tags', () => {
|
|||
wf.actions.addTags(TEST_TAGS);
|
||||
cy.get('body').click(0, 0);
|
||||
wf.getters.workflowTags().click();
|
||||
wf.getters.tagsDropdown().find('input:focus').type(NON_EXISTING_TAG);
|
||||
wf.getters.workflowTagsInput().type(NON_EXISTING_TAG);
|
||||
|
||||
getVisibleSelect()
|
||||
.find('li')
|
||||
|
|
21
cypress/e2e/1858-PAY-can-use-context-menu.ts
Normal file
21
cypress/e2e/1858-PAY-can-use-context-menu.ts
Normal 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);
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
247
cypress/e2e/45-ai-assistant.cy.ts
Normal file
247
cypress/e2e/45-ai-assistant.cy.ts
Normal 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');
|
||||
});
|
||||
});
|
82
cypress/e2e/45-workflow-selector-parameter.cy.ts
Normal file
82
cypress/e2e/45-workflow-selector-parameter.cy.ts
Normal file
|
@ -0,0 +1,82 @@
|
|||
import { EXECUTE_WORKFLOW_NODE_NAME } from '../constants';
|
||||
import { WorkflowPage as WorkflowPageClass, NDV } from '../pages';
|
||||
import { getVisiblePopper } from '../utils';
|
||||
|
||||
const workflowPage = new WorkflowPageClass();
|
||||
const ndv = new NDV();
|
||||
|
||||
describe('Workflow Selector Parameter', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
cy.signinAsOwner();
|
||||
['Get_Weather', 'Search_DB'].forEach((workflowName) => {
|
||||
workflowPage.actions.visit();
|
||||
cy.createFixtureWorkflow(`Test_Subworkflow_${workflowName}.json`, workflowName);
|
||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||
});
|
||||
workflowPage.actions.visit();
|
||||
workflowPage.actions.addInitialNodeToCanvas(EXECUTE_WORKFLOW_NODE_NAME, {
|
||||
keepNdvOpen: true,
|
||||
action: 'Call Another Workflow',
|
||||
});
|
||||
});
|
||||
it('should render sub-workflows list', () => {
|
||||
ndv.getters.resourceLocator('workflowId').should('be.visible');
|
||||
ndv.getters.resourceLocatorInput('workflowId').click();
|
||||
|
||||
getVisiblePopper()
|
||||
.should('have.length', 1)
|
||||
.findChildByTestId('rlc-item')
|
||||
.should('have.length', 2);
|
||||
});
|
||||
|
||||
it('should show required parameter warning', () => {
|
||||
ndv.getters.resourceLocator('workflowId').should('be.visible');
|
||||
ndv.getters.resourceLocatorInput('workflowId').click();
|
||||
ndv.getters.parameterInputIssues('workflowId').should('exist');
|
||||
});
|
||||
|
||||
it('should filter sub-workflows list', () => {
|
||||
ndv.getters.resourceLocator('workflowId').should('be.visible');
|
||||
ndv.getters.resourceLocatorInput('workflowId').click();
|
||||
ndv.getters.resourceLocatorSearch('workflowId').type('Weather');
|
||||
|
||||
getVisiblePopper()
|
||||
.should('have.length', 1)
|
||||
.findChildByTestId('rlc-item')
|
||||
.should('have.length', 1)
|
||||
.click();
|
||||
|
||||
ndv.getters
|
||||
.resourceLocatorInput('workflowId')
|
||||
.find('input')
|
||||
.should('have.value', 'Get_Weather');
|
||||
});
|
||||
|
||||
it('should render sub-workflow links correctly', () => {
|
||||
ndv.getters.resourceLocator('workflowId').should('be.visible');
|
||||
ndv.getters.resourceLocatorInput('workflowId').click();
|
||||
|
||||
getVisiblePopper().findChildByTestId('rlc-item').first().click();
|
||||
|
||||
ndv.getters.resourceLocatorInput('workflowId').find('a').should('exist');
|
||||
cy.getByTestId('radio-button-expression').eq(1).click();
|
||||
ndv.getters.resourceLocatorInput('workflowId').find('a').should('not.exist');
|
||||
});
|
||||
|
||||
it('should switch to ID mode on expression', () => {
|
||||
ndv.getters.resourceLocator('workflowId').should('be.visible');
|
||||
ndv.getters.resourceLocatorInput('workflowId').click();
|
||||
|
||||
getVisiblePopper().findChildByTestId('rlc-item').first().click();
|
||||
ndv.getters
|
||||
.resourceLocatorModeSelector('workflowId')
|
||||
.find('input')
|
||||
.should('have.value', 'From list');
|
||||
cy.getByTestId('radio-button-expression').eq(1).click();
|
||||
ndv.getters
|
||||
.resourceLocatorModeSelector('workflowId')
|
||||
.find('input')
|
||||
.should('have.value', 'By ID');
|
||||
});
|
||||
});
|
53
cypress/fixtures/Test_Subworkflow_Get_Weather.json
Normal file
53
cypress/fixtures/Test_Subworkflow_Get_Weather.json
Normal file
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"name": "Get Weather",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "82eed1ba-179b-4f8f-8a85-b45f0d4e5857",
|
||||
"name": "Execute Workflow Trigger",
|
||||
"type": "n8n-nodes-base.executeWorkflowTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
560,
|
||||
340
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "6ad8dc55-20f3-45af-a724-c7ecac90d338",
|
||||
"name": "response",
|
||||
"value": "Weather is sunny",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "8f3e00f6-fc92-4aba-817b-93d206158bda",
|
||||
"name": "Edit Fields",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
780,
|
||||
340
|
||||
]
|
||||
}
|
||||
],
|
||||
"pinData": {},
|
||||
"connections": {
|
||||
"Execute Workflow Trigger": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Edit Fields",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
64
cypress/fixtures/Test_Subworkflow_Search_DB.json
Normal file
64
cypress/fixtures/Test_Subworkflow_Search_DB.json
Normal file
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"name": "Search DB",
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {},
|
||||
"id": "64465f9b-63de-43f9-8d90-b5b2eb7a2dc7",
|
||||
"name": "Execute Workflow Trigger",
|
||||
"type": "n8n-nodes-base.executeWorkflowTrigger",
|
||||
"typeVersion": 1,
|
||||
"position": [
|
||||
640,
|
||||
380
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "6ad8dc55-20f3-45af-a724-c7ecac90d338",
|
||||
"name": "response",
|
||||
"value": "10 results found",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "b580fd2b-00c8-4a52-8acb-024f204c0947",
|
||||
"name": "Edit Fields",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
860,
|
||||
380
|
||||
]
|
||||
}
|
||||
],
|
||||
"pinData": {},
|
||||
"connections": {
|
||||
"Execute Workflow Trigger": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Edit Fields",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"active": false,
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"versionId": "6026f7a4-f5dc-4c27-9f83-3a02fc6e33ae",
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true,
|
||||
"instanceId": "27cc9b56542ad45b38725555722c50a1c3fee1670bbb67980558314ee08517c4"
|
||||
},
|
||||
"id": "BFFhCdBZmNSkx4qf",
|
||||
"tags": []
|
||||
}
|
|
@ -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();"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
16
cypress/fixtures/aiAssistant/end_session_response.json
Normal file
16
cypress/fixtures/aiAssistant/end_session_response.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
10
cypress/fixtures/aiAssistant/simple_message_response.json
Normal file
10
cypress/fixtures/aiAssistant/simple_message_response.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"sessionId": "1",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"type": "message",
|
||||
"text": "Hey, this is an assistant message"
|
||||
}
|
||||
]
|
||||
}
|
88
cypress/fixtures/aiAssistant/test_workflow.json
Normal file
88
cypress/fixtures/aiAssistant/test_workflow.json
Normal 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": {}
|
||||
}
|
|
@ -26,6 +26,6 @@
|
|||
"cypress-real-events": "^1.12.0",
|
||||
"lodash": "catalog:",
|
||||
"nanoid": "catalog:",
|
||||
"start-server-and-test": "^2.0.3"
|
||||
"start-server-and-test": "^2.0.5"
|
||||
}
|
||||
}
|
||||
|
|
49
cypress/pages/features/ai-assistant.ts
Normal file
49
cypress/pages/features/ai-assistant.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
|
@ -138,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 = {
|
||||
|
@ -175,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();
|
||||
},
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -3,7 +3,7 @@ export function getPopper() {
|
|||
}
|
||||
|
||||
export function getVisiblePopper() {
|
||||
return getPopper().filter(':visible');
|
||||
return getPopper().filter('[aria-hidden="false"]');
|
||||
}
|
||||
|
||||
export function getVisibleSelect() {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "1.55.0",
|
||||
"version": "1.56.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20.15",
|
||||
|
@ -63,7 +63,6 @@
|
|||
],
|
||||
"overrides": {
|
||||
"@types/node": "^18.16.16",
|
||||
"axios": "1.7.3",
|
||||
"chokidar": "3.5.2",
|
||||
"esbuild": "^0.20.2",
|
||||
"formidable": "3.5.1",
|
||||
|
|
62
packages/@n8n/benchmark/Dockerfile
Normal file
62
packages/@n8n/benchmark/Dockerfile
Normal 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" ]
|
55
packages/@n8n/benchmark/README.md
Normal file
55
packages/@n8n/benchmark/README.md
Normal 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/).
|
13
packages/@n8n/benchmark/bin/n8n-benchmark
Executable file
13
packages/@n8n/benchmark/bin/n8n-benchmark
Executable 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 });
|
||||
})();
|
48
packages/@n8n/benchmark/package.json
Normal file
48
packages/@n8n/benchmark/package.json
Normal 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": " "
|
||||
}
|
||||
}
|
42
packages/@n8n/benchmark/scenarios/scenario.schema.json
Normal file
42
packages/@n8n/benchmark/scenarios/scenario.schema.json
Normal 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
|
||||
}
|
|
@ -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": []
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
}
|
21
packages/@n8n/benchmark/src/commands/list.ts
Normal file
21
packages/@n8n/benchmark/src/commands/list.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
39
packages/@n8n/benchmark/src/commands/run.ts
Normal file
39
packages/@n8n/benchmark/src/commands/run.ts
Normal 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);
|
||||
}
|
||||
}
|
50
packages/@n8n/benchmark/src/config/config.ts
Normal file
50
packages/@n8n/benchmark/src/config/config.ts
Normal 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;
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
78
packages/@n8n/benchmark/src/n8nApiClient/n8nApiClient.ts
Normal file
78
packages/@n8n/benchmark/src/n8nApiClient/n8nApiClient.ts
Normal 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));
|
||||
}
|
||||
}
|
|
@ -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[];
|
||||
};
|
|
@ -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}`);
|
||||
}
|
||||
}
|
35
packages/@n8n/benchmark/src/scenario/scenarioDataLoader.ts
Normal file
35
packages/@n8n/benchmark/src/scenario/scenarioDataLoader.ts
Normal 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
67
packages/@n8n/benchmark/src/scenario/scenarioLoader.ts
Normal file
67
packages/@n8n/benchmark/src/scenario/scenarioLoader.ts
Normal 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');
|
||||
}
|
||||
}
|
28
packages/@n8n/benchmark/src/testExecution/k6Executor.ts
Normal file
28
packages/@n8n/benchmark/src/testExecution/k6Executor.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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}`;
|
||||
}
|
||||
}
|
50
packages/@n8n/benchmark/src/testExecution/scenarioRunner.ts
Normal file
50
packages/@n8n/benchmark/src/testExecution/scenarioRunner.ts
Normal 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);
|
||||
}
|
||||
}
|
27
packages/@n8n/benchmark/src/types/scenario.ts
Normal file
27
packages/@n8n/benchmark/src/types/scenario.ts
Normal 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;
|
||||
};
|
9
packages/@n8n/benchmark/tsconfig.build.json
Normal file
9
packages/@n8n/benchmark/tsconfig.build.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": ["./tsconfig.json", "../../../tsconfig.build.json"],
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "dist/build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
11
packages/@n8n/benchmark/tsconfig.json
Normal file
11
packages/@n8n/benchmark/tsconfig.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": ["../../../tsconfig.json", "../../../tsconfig.backend.json"],
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/config",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
@ -4,22 +4,22 @@ import { Config, Env, Nested } from '../decorators';
|
|||
class MemoryConfig {
|
||||
/** Max size of memory cache in bytes */
|
||||
@Env('N8N_CACHE_MEMORY_MAX_SIZE')
|
||||
maxSize = 3 * 1024 * 1024; // 3 MiB
|
||||
maxSize: number = 3 * 1024 * 1024; // 3 MiB
|
||||
|
||||
/** Time to live (in milliseconds) for data cached in memory. */
|
||||
@Env('N8N_CACHE_MEMORY_TTL')
|
||||
ttl = 3600 * 1000; // 1 hour
|
||||
ttl: number = 3600 * 1000; // 1 hour
|
||||
}
|
||||
|
||||
@Config
|
||||
class RedisConfig {
|
||||
/** Prefix for cache keys in Redis. */
|
||||
@Env('N8N_CACHE_REDIS_KEY_PREFIX')
|
||||
prefix = 'redis';
|
||||
prefix: string = 'redis';
|
||||
|
||||
/** Time to live (in milliseconds) for data cached in Redis. 0 for no TTL. */
|
||||
@Env('N8N_CACHE_REDIS_TTL')
|
||||
ttl = 3600 * 1000; // 1 hour
|
||||
ttl: number = 3600 * 1000; // 1 hour
|
||||
}
|
||||
|
||||
@Config
|
||||
|
|
|
@ -7,18 +7,18 @@ class CredentialsOverwrite {
|
|||
* Format: { CREDENTIAL_NAME: { PARAMETER: VALUE }}
|
||||
*/
|
||||
@Env('CREDENTIALS_OVERWRITE_DATA')
|
||||
data = '{}';
|
||||
data: string = '{}';
|
||||
|
||||
/** Internal API endpoint to fetch overwritten credential types from. */
|
||||
@Env('CREDENTIALS_OVERWRITE_ENDPOINT')
|
||||
endpoint = '';
|
||||
endpoint: string = '';
|
||||
}
|
||||
|
||||
@Config
|
||||
export class CredentialsConfig {
|
||||
/** Default name for credentials */
|
||||
@Env('CREDENTIALS_DEFAULT_NAME')
|
||||
defaultName = 'My credentials';
|
||||
defaultName: string = 'My credentials';
|
||||
|
||||
@Nested
|
||||
overwrite: CredentialsOverwrite;
|
||||
|
|
|
@ -4,7 +4,7 @@ import { Config, Env, Nested } from '../decorators';
|
|||
class LoggingConfig {
|
||||
/** Whether database logging is enabled. */
|
||||
@Env('DB_LOGGING_ENABLED')
|
||||
enabled = false;
|
||||
enabled: boolean = false;
|
||||
|
||||
/**
|
||||
* Database logging level. Requires `DB_LOGGING_MAX_EXECUTION_TIME` to be higher than `0`.
|
||||
|
@ -16,7 +16,7 @@ class LoggingConfig {
|
|||
* Only queries that exceed this time (ms) will be logged. Set `0` to disable.
|
||||
*/
|
||||
@Env('DB_LOGGING_MAX_EXECUTION_TIME')
|
||||
maxQueryExecutionTime = 0;
|
||||
maxQueryExecutionTime: number = 0;
|
||||
}
|
||||
|
||||
@Config
|
||||
|
@ -26,38 +26,38 @@ 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')
|
||||
enabled = false;
|
||||
enabled: boolean = false;
|
||||
|
||||
/** SSL certificate authority */
|
||||
@Env('DB_POSTGRESDB_SSL_CA')
|
||||
ca = '';
|
||||
ca: string = '';
|
||||
|
||||
/** SSL certificate */
|
||||
@Env('DB_POSTGRESDB_SSL_CERT')
|
||||
cert = '';
|
||||
cert: string = '';
|
||||
|
||||
/** SSL key */
|
||||
@Env('DB_POSTGRESDB_SSL_KEY')
|
||||
key = '';
|
||||
key: string = '';
|
||||
|
||||
/** If unauthorized SSL connections should be rejected */
|
||||
@Env('DB_POSTGRESDB_SSL_REJECT_UNAUTHORIZED')
|
||||
rejectUnauthorized = true;
|
||||
rejectUnauthorized: boolean = true;
|
||||
}
|
||||
|
||||
@Config
|
||||
class PostgresConfig {
|
||||
/** Postgres database name */
|
||||
@Env('DB_POSTGRESDB_DATABASE')
|
||||
database = 'n8n';
|
||||
database: string = 'n8n';
|
||||
|
||||
/** Postgres database host */
|
||||
@Env('DB_POSTGRESDB_HOST')
|
||||
host = 'localhost';
|
||||
host: string = 'localhost';
|
||||
|
||||
/** Postgres database password */
|
||||
@Env('DB_POSTGRESDB_PASSWORD')
|
||||
password = '';
|
||||
password: string = '';
|
||||
|
||||
/** Postgres database port */
|
||||
@Env('DB_POSTGRESDB_PORT')
|
||||
|
@ -65,15 +65,15 @@ class PostgresConfig {
|
|||
|
||||
/** Postgres database user */
|
||||
@Env('DB_POSTGRESDB_USER')
|
||||
user = 'postgres';
|
||||
user: string = 'postgres';
|
||||
|
||||
/** Postgres database schema */
|
||||
@Env('DB_POSTGRESDB_SCHEMA')
|
||||
schema = 'public';
|
||||
schema: string = 'public';
|
||||
|
||||
/** Postgres database pool size */
|
||||
@Env('DB_POSTGRESDB_POOL_SIZE')
|
||||
poolSize = 2;
|
||||
poolSize: number = 2;
|
||||
|
||||
@Nested
|
||||
ssl: PostgresSSLConfig;
|
||||
|
@ -83,15 +83,15 @@ class PostgresConfig {
|
|||
class MysqlConfig {
|
||||
/** @deprecated MySQL database name */
|
||||
@Env('DB_MYSQLDB_DATABASE')
|
||||
database = 'n8n';
|
||||
database: string = 'n8n';
|
||||
|
||||
/** MySQL database host */
|
||||
@Env('DB_MYSQLDB_HOST')
|
||||
host = 'localhost';
|
||||
host: string = 'localhost';
|
||||
|
||||
/** MySQL database password */
|
||||
@Env('DB_MYSQLDB_PASSWORD')
|
||||
password = '';
|
||||
password: string = '';
|
||||
|
||||
/** MySQL database port */
|
||||
@Env('DB_MYSQLDB_PORT')
|
||||
|
@ -99,14 +99,14 @@ class MysqlConfig {
|
|||
|
||||
/** MySQL database user */
|
||||
@Env('DB_MYSQLDB_USER')
|
||||
user = 'root';
|
||||
user: string = 'root';
|
||||
}
|
||||
|
||||
@Config
|
||||
class SqliteConfig {
|
||||
/** SQLite database file name */
|
||||
@Env('DB_SQLITE_DATABASE')
|
||||
database = 'database.sqlite';
|
||||
database: string = 'database.sqlite';
|
||||
|
||||
/** SQLite database pool size. Set to `0` to disable pooling. */
|
||||
@Env('DB_SQLITE_POOL_SIZE')
|
||||
|
@ -116,7 +116,7 @@ class SqliteConfig {
|
|||
* Enable SQLite WAL mode.
|
||||
*/
|
||||
@Env('DB_SQLITE_ENABLE_WAL')
|
||||
enableWAL = 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')
|
||||
executeVacuumOnStartup = false;
|
||||
executeVacuumOnStartup: boolean = false;
|
||||
}
|
||||
|
||||
@Config
|
||||
|
@ -135,7 +135,7 @@ export class DatabaseConfig {
|
|||
|
||||
/** Prefix for table names */
|
||||
@Env('DB_TABLE_PREFIX')
|
||||
tablePrefix = '';
|
||||
tablePrefix: string = '';
|
||||
|
||||
@Nested
|
||||
logging: LoggingConfig;
|
||||
|
|
|
@ -4,51 +4,51 @@ import { Config, Env, Nested } from '../decorators';
|
|||
class PrometheusMetricsConfig {
|
||||
/** Whether to enable the `/metrics` endpoint to expose Prometheus metrics. */
|
||||
@Env('N8N_METRICS')
|
||||
enable = false;
|
||||
enable: boolean = false;
|
||||
|
||||
/** Prefix for Prometheus metric names. */
|
||||
@Env('N8N_METRICS_PREFIX')
|
||||
prefix = 'n8n_';
|
||||
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 = true;
|
||||
includeDefaultMetrics: boolean = true;
|
||||
|
||||
/** Whether to include a label for workflow ID on workflow metrics. */
|
||||
@Env('N8N_METRICS_INCLUDE_WORKFLOW_ID_LABEL')
|
||||
includeWorkflowIdLabel = false;
|
||||
includeWorkflowIdLabel: boolean = false;
|
||||
|
||||
/** Whether to include a label for node type on node metrics. */
|
||||
@Env('N8N_METRICS_INCLUDE_NODE_TYPE_LABEL')
|
||||
includeNodeTypeLabel = false;
|
||||
includeNodeTypeLabel: boolean = false;
|
||||
|
||||
/** Whether to include a label for credential type on credential metrics. */
|
||||
@Env('N8N_METRICS_INCLUDE_CREDENTIAL_TYPE_LABEL')
|
||||
includeCredentialTypeLabel = false;
|
||||
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 = false;
|
||||
includeApiEndpoints: boolean = false;
|
||||
|
||||
/** Whether to include a label for the path of API endpoint calls. */
|
||||
@Env('N8N_METRICS_INCLUDE_API_PATH_LABEL')
|
||||
includeApiPathLabel = false;
|
||||
includeApiPathLabel: boolean = false;
|
||||
|
||||
/** Whether to include a label for the HTTP method of API endpoint calls. */
|
||||
@Env('N8N_METRICS_INCLUDE_API_METHOD_LABEL')
|
||||
includeApiMethodLabel = false;
|
||||
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 = false;
|
||||
includeApiStatusCodeLabel: boolean = false;
|
||||
|
||||
/** Whether to include metrics for cache hits and misses. */
|
||||
@Env('N8N_METRICS_INCLUDE_CACHE_METRICS')
|
||||
includeCacheMetrics = false;
|
||||
includeCacheMetrics: boolean = false;
|
||||
|
||||
/** Whether to include metrics derived from n8n's internal events */
|
||||
@Env('N8N_METRICS_INCLUDE_MESSAGE_EVENT_BUS_METRICS')
|
||||
includeMessageEventBusMetrics = false;
|
||||
includeMessageEventBusMetrics: boolean = false;
|
||||
}
|
||||
|
||||
@Config
|
||||
|
@ -62,41 +62,41 @@ export class EndpointsConfig {
|
|||
|
||||
/** Path segment for REST API endpoints. */
|
||||
@Env('N8N_ENDPOINT_REST')
|
||||
rest = 'rest';
|
||||
rest: string = 'rest';
|
||||
|
||||
/** Path segment for form endpoints. */
|
||||
@Env('N8N_ENDPOINT_FORM')
|
||||
form = 'form';
|
||||
form: string = 'form';
|
||||
|
||||
/** Path segment for test form endpoints. */
|
||||
@Env('N8N_ENDPOINT_FORM_TEST')
|
||||
formTest = 'form-test';
|
||||
formTest: string = 'form-test';
|
||||
|
||||
/** Path segment for waiting form endpoints. */
|
||||
@Env('N8N_ENDPOINT_FORM_WAIT')
|
||||
formWaiting = 'form-waiting';
|
||||
formWaiting: string = 'form-waiting';
|
||||
|
||||
/** Path segment for webhook endpoints. */
|
||||
@Env('N8N_ENDPOINT_WEBHOOK')
|
||||
webhook = 'webhook';
|
||||
webhook: string = 'webhook';
|
||||
|
||||
/** Path segment for test webhook endpoints. */
|
||||
@Env('N8N_ENDPOINT_WEBHOOK_TEST')
|
||||
webhookTest = 'webhook-test';
|
||||
webhookTest: string = 'webhook-test';
|
||||
|
||||
/** Path segment for waiting webhook endpoints. */
|
||||
@Env('N8N_ENDPOINT_WEBHOOK_WAIT')
|
||||
webhookWaiting = 'webhook-waiting';
|
||||
webhookWaiting: string = 'webhook-waiting';
|
||||
|
||||
/** Whether to disable n8n's UI (frontend). */
|
||||
@Env('N8N_DISABLE_UI')
|
||||
disableUi = false;
|
||||
disableUi: boolean = false;
|
||||
|
||||
/** Whether to disable production webhooks on the main process, when using webhook-specific processes. */
|
||||
@Env('N8N_DISABLE_PRODUCTION_MAIN_PROCESS')
|
||||
disableProductionWebhooksOnMainProcess = false;
|
||||
disableProductionWebhooksOnMainProcess: boolean = false;
|
||||
|
||||
/** Colon-delimited list of additional endpoints to not open the UI on. */
|
||||
@Env('N8N_ADDITIONAL_NON_UI_ROUTES')
|
||||
additionalNonUIRoutes = '';
|
||||
additionalNonUIRoutes: string = '';
|
||||
}
|
||||
|
|
|
@ -4,22 +4,22 @@ import { Config, Env, Nested } from '../decorators';
|
|||
class LogWriterConfig {
|
||||
/* of event log files to keep */
|
||||
@Env('N8N_EVENTBUS_LOGWRITER_KEEPLOGCOUNT')
|
||||
keepLogCount = 3;
|
||||
keepLogCount: number = 3;
|
||||
|
||||
/** Max size (in KB) of an event log file before a new one is started */
|
||||
@Env('N8N_EVENTBUS_LOGWRITER_MAXFILESIZEINKB')
|
||||
maxFileSizeInKB = 10240; // 10 MB
|
||||
maxFileSizeInKB: number = 10240; // 10 MB
|
||||
|
||||
/** Basename of event log file */
|
||||
@Env('N8N_EVENTBUS_LOGWRITER_LOGBASENAME')
|
||||
logBaseName = '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')
|
||||
checkUnsentInterval = 0;
|
||||
checkUnsentInterval: number = 0;
|
||||
|
||||
/** Endpoint to retrieve n8n version information from */
|
||||
@Nested
|
||||
|
|
|
@ -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')
|
||||
updateInterval = 300;
|
||||
updateInterval: number = 300;
|
||||
|
||||
/** Whether to prefer GET over LIST when fetching secrets from Hashicorp Vault */
|
||||
@Env('N8N_EXTERNAL_SECRETS_PREFER_GET')
|
||||
preferGet = false;
|
||||
preferGet: boolean = false;
|
||||
}
|
||||
|
|
|
@ -4,29 +4,29 @@ 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')
|
||||
name = '';
|
||||
name: string = '';
|
||||
|
||||
/** Region of the n8n bucket in S3-compatible external storage @example "us-east-1" */
|
||||
@Env('N8N_EXTERNAL_STORAGE_S3_BUCKET_REGION')
|
||||
region = '';
|
||||
region: string = '';
|
||||
}
|
||||
|
||||
@Config
|
||||
class S3CredentialsConfig {
|
||||
/** Access key in S3-compatible external storage */
|
||||
@Env('N8N_EXTERNAL_STORAGE_S3_ACCESS_KEY')
|
||||
accessKey = '';
|
||||
accessKey: string = '';
|
||||
|
||||
/** Access secret in S3-compatible external storage */
|
||||
@Env('N8N_EXTERNAL_STORAGE_S3_ACCESS_SECRET')
|
||||
accessSecret = '';
|
||||
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')
|
||||
host = '';
|
||||
host: string = '';
|
||||
|
||||
@Nested
|
||||
bucket: S3BucketConfig;
|
||||
|
|
|
@ -47,7 +47,7 @@ export class NodesConfig {
|
|||
|
||||
/** Node type to use as error trigger */
|
||||
@Env('NODES_ERROR_TRIGGER_TYPE')
|
||||
errorTriggerType = 'n8n-nodes-base.errorTrigger';
|
||||
errorTriggerType: string = 'n8n-nodes-base.errorTrigger';
|
||||
|
||||
@Nested
|
||||
communityPackages: CommunityPackagesConfig;
|
||||
|
|
|
@ -4,13 +4,13 @@ import { Config, Env } from '../decorators';
|
|||
export class PublicApiConfig {
|
||||
/** Whether to disable the Public API */
|
||||
@Env('N8N_PUBLIC_API_DISABLED')
|
||||
disabled = false;
|
||||
disabled: boolean = false;
|
||||
|
||||
/** Path segment for the Public API */
|
||||
@Env('N8N_PUBLIC_API_ENDPOINT')
|
||||
path = 'api';
|
||||
path: string = 'api';
|
||||
|
||||
/** Whether to disable the Swagger UI for the Public API */
|
||||
@Env('N8N_PUBLIC_API_SWAGGERUI_DISABLED')
|
||||
swaggerUiDisabled = false;
|
||||
swaggerUiDisabled: boolean = false;
|
||||
}
|
||||
|
|
|
@ -4,83 +4,83 @@ import { Config, Env, Nested } from '../decorators';
|
|||
class HealthConfig {
|
||||
/** Whether to enable the worker health check endpoint `/healthz`. */
|
||||
@Env('QUEUE_HEALTH_CHECK_ACTIVE')
|
||||
active = false;
|
||||
active: boolean = false;
|
||||
|
||||
/** Port for worker to respond to health checks requests on, if enabled. */
|
||||
@Env('QUEUE_HEALTH_CHECK_PORT')
|
||||
port = 5678;
|
||||
port: number = 5678;
|
||||
}
|
||||
|
||||
@Config
|
||||
class RedisConfig {
|
||||
/** Redis database for Bull queue. */
|
||||
@Env('QUEUE_BULL_REDIS_DB')
|
||||
db = 0;
|
||||
db: number = 0;
|
||||
|
||||
/** Redis host for Bull queue. */
|
||||
@Env('QUEUE_BULL_REDIS_HOST')
|
||||
host = 'localhost';
|
||||
host: string = 'localhost';
|
||||
|
||||
/** Password to authenticate with Redis. */
|
||||
@Env('QUEUE_BULL_REDIS_PASSWORD')
|
||||
password = '';
|
||||
password: string = '';
|
||||
|
||||
/** Port for Redis to listen on. */
|
||||
@Env('QUEUE_BULL_REDIS_PORT')
|
||||
port = 6379;
|
||||
port: number = 6379;
|
||||
|
||||
/** Max cumulative timeout (in milliseconds) of connection retries before process exit. */
|
||||
@Env('QUEUE_BULL_REDIS_TIMEOUT_THRESHOLD')
|
||||
timeoutThreshold = 10_000;
|
||||
timeoutThreshold: number = 10_000;
|
||||
|
||||
/** Redis username. Redis 6.0 or higher required. */
|
||||
@Env('QUEUE_BULL_REDIS_USERNAME')
|
||||
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 = '';
|
||||
clusterNodes: string = '';
|
||||
|
||||
/** Whether to enable TLS on Redis connections. */
|
||||
@Env('QUEUE_BULL_REDIS_TLS')
|
||||
tls = false;
|
||||
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 = 30_000;
|
||||
lockDuration: number = 30_000;
|
||||
|
||||
/** How often (in milliseconds) a worker must renew the lease. */
|
||||
@Env('QUEUE_WORKER_LOCK_RENEW_TIME')
|
||||
lockRenewTime = 15_000;
|
||||
lockRenewTime: number = 15_000;
|
||||
|
||||
/** How often (in milliseconds) Bull must check for stalled jobs. `0` to disable. */
|
||||
@Env('QUEUE_WORKER_STALLED_INTERVAL')
|
||||
stalledInterval = 30_000;
|
||||
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 = 1;
|
||||
maxStalledCount: number = 1;
|
||||
}
|
||||
|
||||
@Config
|
||||
class BullConfig {
|
||||
/** Prefix for Bull keys on Redis. @example 'bull:jobs:23' */
|
||||
@Env('QUEUE_BULL_PREFIX')
|
||||
prefix = 'bull';
|
||||
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 = 60; // watchdog 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 = 30;
|
||||
gracefulShutdownTimeout: number = 30;
|
||||
|
||||
@Nested
|
||||
settings: SettingsConfig;
|
||||
|
|
|
@ -4,9 +4,9 @@ import { Config, Env } from '../decorators';
|
|||
export class TemplatesConfig {
|
||||
/** Whether to load workflow templates. */
|
||||
@Env('N8N_TEMPLATES_ENABLED')
|
||||
enabled = true;
|
||||
enabled: boolean = true;
|
||||
|
||||
/** Host to retrieve workflow templates from endpoints. */
|
||||
@Env('N8N_TEMPLATES_HOST')
|
||||
host = 'https://api.n8n.io/api/';
|
||||
host: string = 'https://api.n8n.io/api/';
|
||||
}
|
||||
|
|
|
@ -4,26 +4,26 @@ import { Config, Env, Nested } from '../decorators';
|
|||
class SmtpAuth {
|
||||
/** SMTP login username */
|
||||
@Env('N8N_SMTP_USER')
|
||||
user = '';
|
||||
user: string = '';
|
||||
|
||||
/** SMTP login password */
|
||||
@Env('N8N_SMTP_PASS')
|
||||
pass = '';
|
||||
pass: string = '';
|
||||
|
||||
/** SMTP OAuth Service Client */
|
||||
@Env('N8N_SMTP_OAUTH_SERVICE_CLIENT')
|
||||
serviceClient = '';
|
||||
serviceClient: string = '';
|
||||
|
||||
/** SMTP OAuth Private Key */
|
||||
@Env('N8N_SMTP_OAUTH_PRIVATE_KEY')
|
||||
privateKey = '';
|
||||
privateKey: string = '';
|
||||
}
|
||||
|
||||
@Config
|
||||
class SmtpConfig {
|
||||
/** SMTP server host */
|
||||
@Env('N8N_SMTP_HOST')
|
||||
host = '';
|
||||
host: string = '';
|
||||
|
||||
/** SMTP server port */
|
||||
@Env('N8N_SMTP_PORT')
|
||||
|
@ -39,7 +39,7 @@ class SmtpConfig {
|
|||
|
||||
/** How to display sender name */
|
||||
@Env('N8N_SMTP_SENDER')
|
||||
sender = '';
|
||||
sender: string = '';
|
||||
|
||||
@Nested
|
||||
auth: SmtpAuth;
|
||||
|
@ -49,19 +49,19 @@ class SmtpConfig {
|
|||
export class TemplateConfig {
|
||||
/** Overrides default HTML template for inviting new people (use full path) */
|
||||
@Env('N8N_UM_EMAIL_TEMPLATES_INVITE')
|
||||
invite = '';
|
||||
invite: string = '';
|
||||
|
||||
/** Overrides default HTML template for resetting password (use full path) */
|
||||
@Env('N8N_UM_EMAIL_TEMPLATES_PWRESET')
|
||||
passwordReset = '';
|
||||
passwordReset: string = '';
|
||||
|
||||
/** Overrides default HTML template for notifying that a workflow was shared (use full path) */
|
||||
@Env('N8N_UM_EMAIL_TEMPLATES_WORKFLOW_SHARED')
|
||||
workflowShared = '';
|
||||
workflowShared: string = '';
|
||||
|
||||
/** Overrides default HTML template for notifying that credentials were shared (use full path) */
|
||||
@Env('N8N_UM_EMAIL_TEMPLATES_CREDENTIALS_SHARED')
|
||||
credentialsShared = '';
|
||||
credentialsShared: string = '';
|
||||
}
|
||||
|
||||
@Config
|
||||
|
|
|
@ -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')
|
||||
enabled = true;
|
||||
enabled: boolean = true;
|
||||
|
||||
/** Endpoint to retrieve n8n version information from */
|
||||
@Env('N8N_VERSION_NOTIFICATIONS_ENDPOINT')
|
||||
endpoint = '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')
|
||||
infoUrl = 'https://docs.n8n.io/hosting/installation/updating/';
|
||||
infoUrl: string = 'https://docs.n8n.io/hosting/installation/updating/';
|
||||
}
|
||||
|
|
|
@ -4,11 +4,11 @@ import { Config, Env } from '../decorators';
|
|||
export class WorkflowsConfig {
|
||||
/** Default name for workflow */
|
||||
@Env('WORKFLOWS_DEFAULT_NAME')
|
||||
defaultName = 'My workflow';
|
||||
defaultName: string = 'My workflow';
|
||||
|
||||
/** Show onboarding flow in new workflow */
|
||||
@Env('N8N_ONBOARDING_FLOW_DISABLED')
|
||||
onboardingFlowDisabled = false;
|
||||
onboardingFlowDisabled: boolean = false;
|
||||
|
||||
/** Default option for which workflows may call the current workflow */
|
||||
@Env('N8N_WORKFLOW_CALLER_POLICY_DEFAULT_OPTION')
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -51,19 +51,19 @@ export class GlobalConfig {
|
|||
|
||||
/** Path n8n is deployed to */
|
||||
@Env('N8N_PATH')
|
||||
path = '/';
|
||||
path: string = '/';
|
||||
|
||||
/** Host name n8n can be reached */
|
||||
@Env('N8N_HOST')
|
||||
host = 'localhost';
|
||||
host: string = 'localhost';
|
||||
|
||||
/** HTTP port n8n can be reached */
|
||||
@Env('N8N_PORT')
|
||||
port = 5678;
|
||||
port: number = 5678;
|
||||
|
||||
/** IP address n8n should listen on */
|
||||
@Env('N8N_LISTEN_ADDRESS')
|
||||
listen_address = '0.0.0.0';
|
||||
listen_address: string = '0.0.0.0';
|
||||
|
||||
/** HTTP Protocol via which n8n can be reached */
|
||||
@Env('N8N_PROTOCOL')
|
||||
|
|
|
@ -232,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({
|
||||
|
|
|
@ -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',
|
||||
|
@ -305,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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -9,6 +9,7 @@ import type {
|
|||
INodeType,
|
||||
INodeTypeDescription,
|
||||
SupplyData,
|
||||
INodeParameterResourceLocator,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { BaseRetriever, type BaseRetrieverInput } from '@langchain/core/retrievers';
|
||||
|
@ -41,7 +42,7 @@ export class RetrieverWorkflow implements INodeType {
|
|||
name: 'retrieverWorkflow',
|
||||
icon: 'fa:box-open',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
version: [1, 1.1],
|
||||
description: 'Use an n8n Workflow as Retriever',
|
||||
defaults: {
|
||||
name: 'Workflow Retriever',
|
||||
|
@ -105,12 +106,26 @@ export class RetrieverWorkflow implements INodeType {
|
|||
displayOptions: {
|
||||
show: {
|
||||
source: ['database'],
|
||||
'@version': [{ _cnd: { eq: 1 } }],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
description: 'The workflow to execute',
|
||||
},
|
||||
{
|
||||
displayName: 'Workflow',
|
||||
name: 'workflowId',
|
||||
type: 'workflowSelector',
|
||||
displayOptions: {
|
||||
show: {
|
||||
source: ['database'],
|
||||
'@version': [{ _cnd: { gte: 1.1 } }],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// source:parameter
|
||||
|
@ -301,11 +316,21 @@ export class RetrieverWorkflow implements INodeType {
|
|||
|
||||
const workflowInfo: IExecuteWorkflowInfo = {};
|
||||
if (source === 'database') {
|
||||
// Read workflow from database
|
||||
workflowInfo.id = this.executeFunctions.getNodeParameter(
|
||||
'workflowId',
|
||||
itemIndex,
|
||||
) as string;
|
||||
const nodeVersion = this.executeFunctions.getNode().typeVersion;
|
||||
if (nodeVersion === 1) {
|
||||
workflowInfo.id = this.executeFunctions.getNodeParameter(
|
||||
'workflowId',
|
||||
itemIndex,
|
||||
) as string;
|
||||
} else {
|
||||
const { value } = this.executeFunctions.getNodeParameter(
|
||||
'workflowId',
|
||||
itemIndex,
|
||||
{},
|
||||
) as INodeParameterResourceLocator;
|
||||
workflowInfo.id = value as string;
|
||||
}
|
||||
|
||||
baseMetadata.workflowId = workflowInfo.id;
|
||||
} else if (source === 'parameter') {
|
||||
// Read workflow from parameter
|
||||
|
|
|
@ -8,6 +8,7 @@ import type {
|
|||
SupplyData,
|
||||
ExecutionError,
|
||||
IDataObject,
|
||||
INodeParameterResourceLocator,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeConnectionType, NodeOperationError, jsonParse } from 'n8n-workflow';
|
||||
import type { SetField, SetNodeOptions } from 'n8n-nodes-base/dist/nodes/Set/v2/helpers/interfaces';
|
||||
|
@ -32,7 +33,7 @@ export class ToolWorkflow implements INodeType {
|
|||
name: 'toolWorkflow',
|
||||
icon: 'fa:network-wired',
|
||||
group: ['transform'],
|
||||
version: [1, 1.1],
|
||||
version: [1, 1.1, 1.2],
|
||||
description: 'Uses another n8n workflow as a tool. Allows packaging any n8n node(s) as a tool.',
|
||||
defaults: {
|
||||
name: 'Call n8n Workflow Tool',
|
||||
|
@ -142,6 +143,7 @@ export class ToolWorkflow implements INodeType {
|
|||
displayOptions: {
|
||||
show: {
|
||||
source: ['database'],
|
||||
'@version': [{ _cnd: { lte: 1.1 } }],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
|
@ -150,6 +152,20 @@ export class ToolWorkflow implements INodeType {
|
|||
hint: 'Can be found in the URL of the workflow',
|
||||
},
|
||||
|
||||
{
|
||||
displayName: 'Workflow',
|
||||
name: 'workflowId',
|
||||
type: 'workflowSelector',
|
||||
displayOptions: {
|
||||
show: {
|
||||
source: ['database'],
|
||||
'@version': [{ _cnd: { gte: 1.2 } }],
|
||||
},
|
||||
},
|
||||
default: '',
|
||||
required: true,
|
||||
},
|
||||
|
||||
// ----------------------------------
|
||||
// source:parameter
|
||||
// ----------------------------------
|
||||
|
@ -368,7 +384,17 @@ export class ToolWorkflow implements INodeType {
|
|||
const workflowInfo: IExecuteWorkflowInfo = {};
|
||||
if (source === 'database') {
|
||||
// Read workflow from database
|
||||
workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string;
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
if (nodeVersion <= 1.1) {
|
||||
workflowInfo.id = this.getNodeParameter('workflowId', itemIndex) as string;
|
||||
} else {
|
||||
const { value } = this.getNodeParameter(
|
||||
'workflowId',
|
||||
itemIndex,
|
||||
{},
|
||||
) as INodeParameterResourceLocator;
|
||||
workflowInfo.id = value as string;
|
||||
}
|
||||
} else if (source === 'parameter') {
|
||||
// Read workflow from parameter
|
||||
const workflowJson = this.getNodeParameter('workflowJson', itemIndex) as string;
|
||||
|
|
|
@ -4,7 +4,12 @@ import { OpenAIAssistantRunnable } from 'langchain/experimental/openai_assistant
|
|||
import type { OpenAIToolType } from 'langchain/dist/experimental/openai_assistant/schema';
|
||||
import { OpenAI as OpenAIClient } from 'openai';
|
||||
|
||||
import { NodeConnectionType, NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
|
||||
import {
|
||||
ApplicationError,
|
||||
NodeConnectionType,
|
||||
NodeOperationError,
|
||||
updateDisplayOptions,
|
||||
} from 'n8n-workflow';
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
|
@ -228,25 +233,36 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
|
|||
}
|
||||
}
|
||||
|
||||
const response = await agentExecutor.withConfig(getTracingConfig(this)).invoke(chainValues);
|
||||
if (memory) {
|
||||
await memory.saveContext({ input }, { output: response.output });
|
||||
let filteredResponse: IDataObject = {};
|
||||
try {
|
||||
const response = await agentExecutor.withConfig(getTracingConfig(this)).invoke(chainValues);
|
||||
if (memory) {
|
||||
await memory.saveContext({ input }, { output: response.output });
|
||||
|
||||
if (response.threadId && response.runId) {
|
||||
const threadRun = await client.beta.threads.runs.retrieve(response.threadId, response.runId);
|
||||
response.usage = threadRun.usage;
|
||||
if (response.threadId && response.runId) {
|
||||
const threadRun = await client.beta.threads.runs.retrieve(
|
||||
response.threadId,
|
||||
response.runId,
|
||||
);
|
||||
response.usage = threadRun.usage;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
options.preserveOriginalTools !== false &&
|
||||
nodeVersion >= 1.3 &&
|
||||
(assistantTools ?? [])?.length
|
||||
) {
|
||||
await client.beta.assistants.update(assistantId, {
|
||||
tools: assistantTools,
|
||||
});
|
||||
}
|
||||
filteredResponse = omit(response, ['signal', 'timeout']) as IDataObject;
|
||||
} catch (error) {
|
||||
if (!(error instanceof ApplicationError)) {
|
||||
throw new NodeOperationError(this.getNode(), error.message, { itemIndex: i });
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
options.preserveOriginalTools !== false &&
|
||||
nodeVersion >= 1.3 &&
|
||||
(assistantTools ?? [])?.length
|
||||
) {
|
||||
await client.beta.assistants.update(assistantId, {
|
||||
tools: assistantTools,
|
||||
});
|
||||
}
|
||||
const filteredResponse = omit(response, ['signal', 'timeout']);
|
||||
return [{ json: filteredResponse, pairedItem: { item: i } }];
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/n8n-nodes-langchain",
|
||||
"version": "1.55.0",
|
||||
"version": "1.56.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
|
|
@ -492,6 +492,29 @@ module.exports = {
|
|||
};
|
||||
},
|
||||
},
|
||||
|
||||
'no-untyped-config-class-field': {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce explicit typing of config class fields',
|
||||
recommended: 'error',
|
||||
},
|
||||
messages: {
|
||||
noUntypedConfigClassField:
|
||||
'Class field must have an explicit type annotation, e.g. `field: type = value`. See: https://github.com/n8n-io/n8n/pull/10433',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
PropertyDefinition(node) {
|
||||
if (!node.typeAnnotation) {
|
||||
context.report({ node: node.key, messageId: 'noUntypedConfigClassField' });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const isJsonParseCall = (node) =>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n",
|
||||
"version": "1.55.0",
|
||||
"version": "1.56.0",
|
||||
"description": "n8n Workflow Automation Tool",
|
||||
"main": "dist/index",
|
||||
"types": "dist/index.d.ts",
|
||||
|
@ -93,7 +93,7 @@
|
|||
"@n8n_io/ai-assistant-sdk": "1.9.4",
|
||||
"@n8n_io/license-sdk": "2.13.1",
|
||||
"@oclif/core": "4.0.7",
|
||||
"@rudderstack/rudder-sdk-node": "2.0.7",
|
||||
"@rudderstack/rudder-sdk-node": "2.0.9",
|
||||
"@sentry/integrations": "7.87.0",
|
||||
"@sentry/node": "7.87.0",
|
||||
"aws4": "1.11.0",
|
||||
|
@ -171,6 +171,7 @@
|
|||
"ws": "8.17.1",
|
||||
"xml2js": "catalog:",
|
||||
"xmllint-wasm": "3.0.1",
|
||||
"xss": "^1.0.14",
|
||||
"yamljs": "0.3.0",
|
||||
"zod": "3.22.4"
|
||||
}
|
||||
|
|
|
@ -23,8 +23,8 @@ if (publicApiEnabled) {
|
|||
|
||||
function copyUserManagementEmailTemplates() {
|
||||
const templates = {
|
||||
source: path.resolve(ROOT_DIR, 'src', 'UserManagement', 'email', 'templates'),
|
||||
destination: path.resolve(ROOT_DIR, 'dist', 'UserManagement', 'email'),
|
||||
source: path.resolve(ROOT_DIR, 'src', 'user-management', 'email', 'templates'),
|
||||
destination: path.resolve(ROOT_DIR, 'dist', 'user-management', 'email'),
|
||||
};
|
||||
|
||||
shell.cp('-r', templates.source, templates.destination);
|
||||
|
|
|
@ -1,73 +0,0 @@
|
|||
import { ValidationError, validate } from 'class-validator';
|
||||
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
||||
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
|
||||
import type { TagEntity } from '@db/entities/TagEntity';
|
||||
import type { User } from '@db/entities/User';
|
||||
import type {
|
||||
UserRoleChangePayload,
|
||||
UserSettingsUpdatePayload,
|
||||
UserUpdatePayload,
|
||||
} from '@/requests';
|
||||
import { BadRequestError } from './errors/response-errors/bad-request.error';
|
||||
import { NoXss } from './databases/utils/customValidators';
|
||||
|
||||
export async function validateEntity(
|
||||
entity:
|
||||
| WorkflowEntity
|
||||
| CredentialsEntity
|
||||
| TagEntity
|
||||
| User
|
||||
| UserUpdatePayload
|
||||
| UserRoleChangePayload
|
||||
| UserSettingsUpdatePayload,
|
||||
): Promise<void> {
|
||||
const errors = await validate(entity);
|
||||
|
||||
const errorMessages = errors
|
||||
.reduce<string[]>((acc, cur) => {
|
||||
if (!cur.constraints) return acc;
|
||||
acc.push(...Object.values(cur.constraints));
|
||||
return acc;
|
||||
}, [])
|
||||
.join(' | ');
|
||||
|
||||
if (errorMessages) {
|
||||
throw new BadRequestError(errorMessages);
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_EXECUTIONS_GET_ALL_LIMIT = 20;
|
||||
|
||||
class StringWithNoXss {
|
||||
@NoXss()
|
||||
value: string;
|
||||
|
||||
constructor(value: string) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Temporary solution until we implement payload validation middleware
|
||||
export async function validateRecordNoXss(record: Record<string, string>) {
|
||||
const errors: ValidationError[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
const stringWithNoXss = new StringWithNoXss(value);
|
||||
const validationErrors = await validate(stringWithNoXss);
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
const error = new ValidationError();
|
||||
error.property = key;
|
||||
error.constraints = validationErrors[0].constraints;
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
const errorMessages = errors
|
||||
.map((error) => `${error.property}: ${Object.values(error.constraints ?? {}).join(', ')}`)
|
||||
.join(' | ');
|
||||
|
||||
throw new BadRequestError(errorMessages);
|
||||
}
|
||||
}
|
|
@ -25,7 +25,7 @@ import type {
|
|||
StartNodeData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import type { ActiveWorkflowManager } from '@/ActiveWorkflowManager';
|
||||
import type { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||
|
||||
import type { WorkflowExecute } from 'n8n-core';
|
||||
|
||||
|
@ -39,7 +39,7 @@ import type { CredentialsRepository } from '@db/repositories/credentials.reposit
|
|||
import type { SettingsRepository } from '@db/repositories/settings.repository';
|
||||
import type { UserRepository } from '@db/repositories/user.repository';
|
||||
import type { WorkflowRepository } from '@db/repositories/workflow.repository';
|
||||
import type { ExternalHooks } from './ExternalHooks';
|
||||
import type { ExternalHooks } from './external-hooks';
|
||||
import type { LICENSE_FEATURES, LICENSE_QUOTAS } from './constants';
|
||||
import type { WorkflowWithSharingsAndCredentials } from './workflows/workflows.types';
|
||||
import type { RunningJobSummary } from './scaling/types';
|
||||
|
|
|
@ -10,7 +10,7 @@ import type { HttpError } from 'express-openapi-validator/dist/framework/types';
|
|||
import type { OpenAPIV3 } from 'openapi-types';
|
||||
import type { JsonObject } from 'swagger-ui-express';
|
||||
|
||||
import { License } from '@/License';
|
||||
import { License } from '@/license';
|
||||
import { UserRepository } from '@db/repositories/user.repository';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
import type { AuthenticatedRequest } from '@/requests';
|
||||
|
|
|
@ -8,7 +8,7 @@ export = {
|
|||
globalScope('securityAudit:generate'),
|
||||
async (req: AuditRequest.Generate, res: Response): Promise<Response> => {
|
||||
try {
|
||||
const { SecurityAuditService } = await import('@/security-audit/SecurityAudit.service');
|
||||
const { SecurityAuditService } = await import('@/security-audit/security-audit.service');
|
||||
const result = await Container.get(SecurityAuditService).run(
|
||||
req.body?.additionalOptions?.categories,
|
||||
req.body?.additionalOptions?.daysAbandonedWorkflow,
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
import type express from 'express';
|
||||
|
||||
import { CredentialsHelper } from '@/CredentialsHelper';
|
||||
import { CredentialTypes } from '@/CredentialTypes';
|
||||
import { CredentialsHelper } from '@/credentials-helper';
|
||||
import { CredentialTypes } from '@/credential-types';
|
||||
import type { CredentialsEntity } from '@db/entities/CredentialsEntity';
|
||||
import type { CredentialTypeRequest, CredentialRequest } from '../../../types';
|
||||
import { projectScope } from '../../shared/middlewares/global.middleware';
|
||||
|
|
|
@ -3,8 +3,8 @@
|
|||
import type express from 'express';
|
||||
import { validate } from 'jsonschema';
|
||||
|
||||
import { CredentialsHelper } from '@/CredentialsHelper';
|
||||
import { CredentialTypes } from '@/CredentialTypes';
|
||||
import { CredentialsHelper } from '@/credentials-helper';
|
||||
import { CredentialTypes } from '@/credential-types';
|
||||
import type { CredentialRequest } from '../../../types';
|
||||
import { toJsonSchema } from './credentials.service';
|
||||
import { Container } from 'typedi';
|
||||
|
|
|
@ -10,7 +10,7 @@ import type { ICredentialsDb } from '@/Interfaces';
|
|||
import { CredentialsEntity } from '@db/entities/CredentialsEntity';
|
||||
import { SharedCredentials } from '@db/entities/SharedCredentials';
|
||||
import type { User } from '@db/entities/User';
|
||||
import { ExternalHooks } from '@/ExternalHooks';
|
||||
import { ExternalHooks } from '@/external-hooks';
|
||||
import type { IDependency, IJsonSchema } from '../../../types';
|
||||
import type { CredentialRequest } from '@/requests';
|
||||
import { Container } from 'typedi';
|
||||
|
|
|
@ -2,7 +2,7 @@ import type express from 'express';
|
|||
import { Container } from 'typedi';
|
||||
import { replaceCircularReferences } from 'n8n-workflow';
|
||||
|
||||
import { ActiveExecutions } from '@/ActiveExecutions';
|
||||
import { ActiveExecutions } from '@/active-executions';
|
||||
import { validCursor } from '../../shared/middlewares/global.middleware';
|
||||
import type { ExecutionRequest } from '../../../types';
|
||||
import { getSharedWorkflowIds } from '../workflows/workflows.service';
|
||||
|
|
|
@ -7,11 +7,11 @@ import type { FindOptionsWhere } from '@n8n/typeorm';
|
|||
import { In, Like, QueryFailedError } from '@n8n/typeorm';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { ActiveWorkflowManager } from '@/ActiveWorkflowManager';
|
||||
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||
import config from '@/config';
|
||||
import { WorkflowEntity } from '@db/entities/WorkflowEntity';
|
||||
import { ExternalHooks } from '@/ExternalHooks';
|
||||
import { addNodeIds, replaceInvalidCredentials } from '@/WorkflowHelpers';
|
||||
import { ExternalHooks } from '@/external-hooks';
|
||||
import { addNodeIds, replaceInvalidCredentials } from '@/workflow-helpers';
|
||||
import type { WorkflowRequest } from '../../../types';
|
||||
import { projectScope, validCursor } from '../../shared/middlewares/global.middleware';
|
||||
import { encodeNextCursor } from '../../shared/services/pagination.service';
|
||||
|
@ -26,7 +26,7 @@ import {
|
|||
updateTags,
|
||||
} from './workflows.service';
|
||||
import { WorkflowService } from '@/workflows/workflow.service';
|
||||
import { WorkflowHistoryService } from '@/workflows/workflowHistory/workflowHistory.service.ee';
|
||||
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service.ee';
|
||||
import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository';
|
||||
import { TagRepository } from '@/databases/repositories/tag.repository';
|
||||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||
|
|
|
@ -8,8 +8,8 @@ import { WorkflowRepository } from '@db/repositories/workflow.repository';
|
|||
import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository';
|
||||
import type { Project } from '@/databases/entities/Project';
|
||||
import { TagRepository } from '@db/repositories/tag.repository';
|
||||
import { License } from '@/License';
|
||||
import { WorkflowSharingService } from '@/workflows/workflowSharing.service';
|
||||
import { License } from '@/license';
|
||||
import { WorkflowSharingService } from '@/workflows/workflow-sharing.service';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import config from '@/config';
|
||||
|
||||
|
|
|
@ -2,13 +2,13 @@
|
|||
import type express from 'express';
|
||||
import { Container } from 'typedi';
|
||||
|
||||
import { License } from '@/License';
|
||||
import { License } from '@/license';
|
||||
import type { AuthenticatedRequest } from '@/requests';
|
||||
|
||||
import type { PaginatedRequest } from '../../../types';
|
||||
import { decodeCursor } from '../services/pagination.service';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import { userHasScope } from '@/permissions/checkAccess';
|
||||
import { userHasScope } from '@/permissions/check-access';
|
||||
import type { BooleanLicenseFeature } from '@/Interfaces';
|
||||
import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
|
||||
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
import { UserManagementMailer } from './UserManagementMailer';
|
||||
|
||||
export { UserManagementMailer };
|
|
@ -2,8 +2,8 @@ import { LicenseManager } from '@n8n_io/license-sdk';
|
|||
import { InstanceSettings } from 'n8n-core';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import config from '@/config';
|
||||
import { License } from '@/License';
|
||||
import { Logger } from '@/Logger';
|
||||
import { License } from '@/license';
|
||||
import { Logger } from '@/logger';
|
||||
import { N8N_VERSION } from '@/constants';
|
||||
import { mockInstance } from '@test/mocking';
|
||||
import { OrchestrationService } from '@/services/orchestration.service';
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue