Merge branch 'n8n-io:master' into master

This commit is contained in:
Hào Huỳnh 2024-08-22 22:00:25 +07:00 committed by GitHub
commit 65c2bf6d42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
602 changed files with 6969 additions and 2531 deletions

View file

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

View file

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

View file

@ -8,6 +8,10 @@ on:
paths:
- packages/cli/src/databases/**
- .github/workflows/ci-postgres-mysql.yml
pull_request_review:
types: [submitted]
branches:
- 'release/*'
concurrency:
group: db-${{ github.event.pull_request.number || github.ref }}

View file

@ -1,6 +1,10 @@
name: Build, unit test and lint branch
on: [pull_request]
on:
pull_request:
branches:
- '**'
- '!release/*'
jobs:
install-and-build:
@ -9,7 +13,6 @@ jobs:
steps:
- uses: actions/checkout@v4.1.1
with:
repository: n8n-io/n8n
ref: refs/pull/${{ github.event.pull_request.number }}/merge
- run: corepack enable

View file

@ -0,0 +1,43 @@
name: Benchmark Docker Image CI
on:
workflow_dispatch:
push:
branches:
- main
paths:
- 'packages/@n8n/benchmark/**'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.github/workflows/docker-images-benchmark.yml'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.1.1
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.0.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build
uses: docker/build-push-action@v5.1.0
with:
context: .
file: ./packages/@n8n/benchmark/Dockerfile
platforms: linux/amd64
provenance: false
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/n8n-benchmark:latest

View file

@ -6,10 +6,6 @@ on:
- cron: '0 1 * * *'
workflow_dispatch:
inputs:
repository:
description: 'GitHub repository to create image off.'
required: true
default: 'n8n-io/n8n'
branch:
description: 'GitHub branch to create image off.'
required: true
@ -49,7 +45,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v4.1.1
with:
repository: ${{ github.event.inputs.repository || 'n8n-io/n8n' }}
ref: ${{ github.event.inputs.branch || 'master' }}
- name: Set up QEMU

View file

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

View file

@ -22,11 +22,6 @@ on:
required: false
default: 'browsers:node18.12.0-chrome107'
type: string
cache-key:
description: 'Cache key for modules and build artifacts.'
required: false
default: ${{ github.sha }}-${{ inputs.run-env }}-e2e-modules
type: string
record:
description: 'Record test run.'
required: false
@ -78,7 +73,6 @@ jobs:
steps:
- uses: actions/checkout@v4.1.1
with:
repository: n8n-io/n8n
ref: ${{ inputs.branch }}
- name: Checkout PR
@ -111,7 +105,7 @@ jobs:
/github/home/.cache
/github/home/.pnpm-store
./packages/**/dist
key: ${{ inputs.cache-key }}
key: ${{ github.sha }}-e2e
testing:
runs-on: ubuntu-latest
@ -128,7 +122,6 @@ jobs:
steps:
- uses: actions/checkout@v4.1.1
with:
repository: n8n-io/n8n
ref: ${{ inputs.branch }}
- name: Checkout PR
@ -146,7 +139,7 @@ jobs:
/github/home/.cache
/github/home/.pnpm-store
./packages/**/dist
key: ${{ inputs.cache-key }}
key: ${{ github.sha }}-e2e
- name: Install dependencies
run: pnpm install --frozen-lockfile

View file

@ -3,8 +3,9 @@ name: PR E2E
on:
pull_request_review:
types: [submitted]
branch:
branches:
- 'master'
- 'release/*'
concurrency:
group: e2e-${{ github.event.pull_request.number || github.ref }}
@ -18,7 +19,6 @@ jobs:
with:
pr_number: ${{ github.event.pull_request.number }}
user: ${{ github.event.pull_request.user.login || 'PR User' }}
spec: 'e2e/*'
secrets:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

View file

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

View file

@ -56,12 +56,12 @@ jobs:
git push -f origin refs/remotes/origin/${{ github.event.inputs.base-branch }}:refs/heads/release/${{ env.NEXT_RELEASE }}
- name: Push the release branch, and Create the PR
uses: peter-evans/create-pull-request@v5
uses: peter-evans/create-pull-request@v6
with:
base: 'release/${{ env.NEXT_RELEASE }}'
branch: '${{ env.NEXT_RELEASE }}-pr'
branch: 'release-pr/${{ env.NEXT_RELEASE }}'
commit-message: ':rocket: Release ${{ env.NEXT_RELEASE }}'
delete-branch: true
labels: 'release'
labels: release,release:${{ github.event.inputs.release-type }}
title: ':rocket: Release ${{ env.NEXT_RELEASE }}'
body-path: 'CHANGELOG-${{ env.NEXT_RELEASE }}.md'

View file

@ -8,18 +8,15 @@ on:
- 'release/*'
jobs:
publish-release:
if: github.event.pull_request.merged == true
publish-to-npm:
name: Publish to NPM
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
timeout-minutes: 60
if: github.event.pull_request.merged == true
timeout-minutes: 10
env:
NPM_CONFIG_PROVENANCE: true
outputs:
release: ${{ steps.set-release.outputs.release }}
steps:
- name: Checkout
uses: actions/checkout@v4.1.1
@ -51,25 +48,97 @@ jobs:
pnpm publish -r --publish-branch ${{github.event.pull_request.base.ref}} --access public --tag rc --no-git-checks
npm dist-tag rm n8n rc
- id: set-release
run: echo "release=${{ env.RELEASE }}" >> $GITHUB_OUTPUT
publish-to-docker-hub:
name: Publish to DockerHub
needs: [publish-to-npm]
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4.1.1
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.0.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
uses: docker/login-action@v3.0.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build
uses: docker/build-push-action@v5.1.0
with:
context: ./docker/images/n8n
build-args: |
N8N_VERSION=${{ needs.publish-to-npm.outputs.release }}
platforms: linux/amd64,linux/arm64
provenance: false
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/n8n:${{ needs.publish-to-npm.outputs.release }}
ghcr.io/${{ github.repository_owner }}/n8n:${{ needs.publish-to-npm.outputs.release }}
create-github-release:
name: Create a GitHub Release
needs: [publish-to-npm, publish-to-docker-hub]
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
timeout-minutes: 5
permissions:
contents: write
id-token: write
steps:
- name: Create a Release on GitHub
uses: ncipollo/release-action@v1
with:
commit: ${{github.event.pull_request.base.ref}}
tag: 'n8n@${{env.RELEASE}}'
tag: 'n8n@${{ needs.publish-to-npm.outputs.release }}'
prerelease: true
makeLatest: false
body: ${{github.event.pull_request.body}}
trigger-release-note:
name: Trigger a release note
needs: [publish-to-npm, create-github-release]
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Trigger a release note
continue-on-error: true
run: curl -u docsWorkflows:${{ secrets.N8N_WEBHOOK_DOCS_PASSWORD }} --request GET 'https://internal.users.n8n.cloud/webhook/trigger-release-note' --header 'Content-Type:application/json' --data '{"version":"${{env.RELEASE}}"}'
run: curl -u docsWorkflows:${{ secrets.N8N_WEBHOOK_DOCS_PASSWORD }} --request GET 'https://internal.users.n8n.cloud/webhook/trigger-release-note' --header 'Content-Type:application/json' --data '{"version":"${{ needs.publish-to-npm.outputs.release }}"}'
# - name: Merge Release into 'master'
# run: |
# git fetch origin
# git checkout --track origin/master
# git config user.name "Jan Oberhauser"
# git config user.email jan.oberhauser@gmail.com
# git merge --ff n8n@${{env.RELEASE}}
# git push origin master
# git push origin :${{github.event.pull_request.base.ref}}
merge-back-into-master:
name: Merge back into master
needs: [publish-to-npm, create-github-release]
if: ${{ github.event.pull_request.merged == true && !contains(github.event.pull_request.labels.*.name, 'release:patch') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.1.1
with:
fetch-depth: 0
- run: |
git checkout --track origin/master
git config user.name "github-actions[bot]"
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
git merge --ff n8n@${{ needs.publish-to-npm.outputs.release }}
git push origin master
git push origin :${{github.event.pull_request.base.ref}}

View file

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

View file

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

View file

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

View file

@ -11,6 +11,21 @@ describe('Inline expression editor', () => {
cy.on('uncaught:exception', (error) => error.name !== 'ExpressionError');
});
describe('Basic UI functionality', () => {
it('should open and close inline expression preview', () => {
WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.openNode('Schedule');
WorkflowPage.actions.openInlineExpressionEditor();
WorkflowPage.getters.inlineExpressionEditorInput().clear();
WorkflowPage.getters.inlineExpressionEditorInput().click().type('{{');
WorkflowPage.getters.inlineExpressionEditorInput().type('123');
WorkflowPage.getters.inlineExpressionEditorOutput().contains(/^123$/);
// click outside to close
ndv.getters.outputPanel().click();
WorkflowPage.getters.inlineExpressionEditorOutput().should('not.exist');
});
});
describe('Static data', () => {
beforeEach(() => {
WorkflowPage.actions.addNodeToCanvas('Hacker News');

View file

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

View file

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

View file

@ -0,0 +1,21 @@
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
const WorkflowPage = new WorkflowPageClass();
describe('PAY-1858 context menu', () => {
it('can use context menu on saved workflow', () => {
WorkflowPage.actions.visit();
cy.createFixtureWorkflow('Test_workflow_filter.json', 'test');
WorkflowPage.getters.canvasNodes().should('have.length', 5);
WorkflowPage.actions.deleteNodeFromContextMenu('Then');
WorkflowPage.getters.canvasNodes().should('have.length', 4);
WorkflowPage.actions.hitSaveWorkflow();
cy.reload();
WorkflowPage.getters.canvasNodes().should('have.length', 4);
WorkflowPage.actions.deleteNodeFromContextMenu('Code');
WorkflowPage.getters.canvasNodes().should('have.length', 3);
});
});

View file

@ -112,13 +112,13 @@ describe('Credentials', () => {
workflowPage.getters.nodeCredentialsSelect().should('have.length', 2);
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect().find('li').last().click();
getVisibleSelect().find('li').contains('Create New Credential').click();
// This one should show auth type selector
credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2);
cy.get('body').type('{esc}');
workflowPage.getters.nodeCredentialsSelect().last().click();
getVisibleSelect().find('li').last().click();
getVisibleSelect().find('li').contains('Create New Credential').click();
// This one should not show auth type selector
credentialsModal.getters.credentialsAuthTypeSelector().should('not.exist');
});

View file

@ -35,13 +35,14 @@ describe('Personal Settings', () => {
successToast().find('.el-notification__closeBtn').click();
});
});
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
it('not allow malicious values for personal data', () => {
cy.visit('/settings/personal');
INVALID_NAMES.forEach((name) => {
cy.getByTestId('personal-data-form').find('input[name="firstName"]').clear().type(name);
cy.getByTestId('personal-data-form').find('input[name="lastName"]').clear().type(name);
cy.getByTestId('save-settings-button').click();
errorToast().should('contain', 'Malicious firstName | Malicious lastName');
errorToast().should('contain', 'Potentially malicious string | Potentially malicious string');
errorToast().find('.el-notification__closeBtn').click();
});
});

View file

@ -0,0 +1,247 @@
import { NDV, WorkflowPage } from '../pages';
import { AIAssistant } from '../pages/features/ai-assistant';
const wf = new WorkflowPage();
const ndv = new NDV();
const aiAssistant = new AIAssistant();
describe('AI Assistant::disabled', () => {
beforeEach(() => {
aiAssistant.actions.disableAssistant();
wf.actions.visit();
});
it('does not show assistant button if feature is disabled', () => {
aiAssistant.getters.askAssistantFloatingButton().should('not.exist');
});
});
describe('AI Assistant::enabled', () => {
beforeEach(() => {
aiAssistant.actions.enableAssistant();
wf.actions.visit();
});
after(() => {
aiAssistant.actions.disableAssistant();
});
it('renders placeholder UI', () => {
aiAssistant.getters.askAssistantFloatingButton().should('be.visible');
aiAssistant.getters.askAssistantFloatingButton().click();
aiAssistant.getters.askAssistantChat().should('be.visible');
aiAssistant.getters.placeholderMessage().should('be.visible');
aiAssistant.getters.chatInputWrapper().should('not.exist');
aiAssistant.getters.closeChatButton().should('be.visible');
aiAssistant.getters.closeChatButton().click();
aiAssistant.getters.askAssistantChat().should('not.exist');
});
it('should resize assistant chat up', () => {
aiAssistant.getters.askAssistantFloatingButton().click();
aiAssistant.getters.askAssistantSidebarResizer().should('be.visible');
aiAssistant.getters.askAssistantChat().then((element) => {
const { width, left } = element[0].getBoundingClientRect();
cy.drag(aiAssistant.getters.askAssistantSidebarResizer(), [left - 10, 0], {
abs: true,
clickToFinish: true,
});
aiAssistant.getters.askAssistantChat().then((newElement) => {
const newWidth = newElement[0].getBoundingClientRect().width;
expect(newWidth).to.be.greaterThan(width);
});
});
});
it('should resize assistant chat down', () => {
aiAssistant.getters.askAssistantFloatingButton().click();
aiAssistant.getters.askAssistantSidebarResizer().should('be.visible');
aiAssistant.getters.askAssistantChat().then((element) => {
const { width, left } = element[0].getBoundingClientRect();
cy.drag(aiAssistant.getters.askAssistantSidebarResizer(), [left + 10, 0], {
abs: true,
clickToFinish: true,
});
aiAssistant.getters.askAssistantChat().then((newElement) => {
const newWidth = newElement[0].getBoundingClientRect().width;
expect(newWidth).to.be.lessThan(width);
});
});
});
it('should start chat session from node error view', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
wf.actions.openNode('Stop and Error');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
cy.wait('@chatRequest');
aiAssistant.getters.chatMessagesAll().should('have.length', 1);
aiAssistant.getters
.chatMessagesAll()
.eq(0)
.should('contain.text', 'Hey, this is an assistant message');
aiAssistant.getters.nodeErrorViewAssistantButton().should('be.disabled');
});
it('should render chat input correctly', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
wf.actions.openNode('Stop and Error');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
cy.wait('@chatRequest');
// Send button should be disabled when input is empty
aiAssistant.getters.sendMessageButton().should('be.disabled');
aiAssistant.getters.chatInput().type('Yo ');
aiAssistant.getters.sendMessageButton().should('not.be.disabled');
aiAssistant.getters.chatInput().then((element) => {
const { height } = element[0].getBoundingClientRect();
// Shift + Enter should add a new line
aiAssistant.getters.chatInput().type('Hello{shift+enter}there');
aiAssistant.getters.chatInput().then((newElement) => {
const newHeight = newElement[0].getBoundingClientRect().height;
// Chat input should grow as user adds new lines
expect(newHeight).to.be.greaterThan(height);
aiAssistant.getters.sendMessageButton().click();
cy.wait('@chatRequest');
// New lines should be rendered as <br> in the chat
aiAssistant.getters.chatMessagesUser().should('have.length', 1);
aiAssistant.getters.chatMessagesUser().eq(0).find('br').should('have.length', 1);
// Chat input should be cleared now
aiAssistant.getters.chatInput().should('have.value', '');
});
});
});
it('should render and handle quick replies', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
statusCode: 200,
fixture: 'aiAssistant/quick_reply_message_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
wf.actions.openNode('Stop and Error');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
cy.wait('@chatRequest');
aiAssistant.getters.quickReplies().should('have.length', 2);
aiAssistant.getters.quickReplies().eq(0).click();
cy.wait('@chatRequest');
aiAssistant.getters.chatMessagesUser().should('have.length', 1);
aiAssistant.getters.chatMessagesUser().eq(0).should('contain.text', "Sure, let's do it");
});
it('should send message to assistant when node is executed', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
wf.actions.openNode('Edit Fields');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
cy.wait('@chatRequest');
aiAssistant.getters.chatMessagesAssistant().should('have.length', 1);
// Executing the same node should sende a new message to the assistant automatically
ndv.getters.nodeExecuteButton().click();
cy.wait('@chatRequest');
aiAssistant.getters.chatMessagesAssistant().should('have.length', 2);
});
it('should warn before starting a new session', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
wf.actions.openNode('Edit Fields');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
cy.wait('@chatRequest');
aiAssistant.getters.closeChatButton().click();
ndv.getters.backToCanvas().click();
wf.actions.openNode('Stop and Error');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
// Since we already have an active session, a warning should be shown
aiAssistant.getters.newAssistantSessionModal().should('be.visible');
aiAssistant.getters
.newAssistantSessionModal()
.find('button')
.contains('Start new session')
.click();
cy.wait('@chatRequest');
// New session should start with initial assistant message
aiAssistant.getters.chatMessagesAll().should('have.length', 1);
});
it('should apply code diff to code node', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
statusCode: 200,
fixture: 'aiAssistant/code_diff_suggestion_response.json',
}).as('chatRequest');
cy.intercept('POST', '/rest/ai-assistant/chat/apply-suggestion', {
statusCode: 200,
fixture: 'aiAssistant/apply_code_diff_response.json',
}).as('applySuggestion');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
wf.actions.openNode('Code');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click({ force: true });
cy.wait('@chatRequest');
// Should have two assistant messages
aiAssistant.getters.chatMessagesAll().should('have.length', 2);
aiAssistant.getters.codeDiffs().should('have.length', 1);
aiAssistant.getters.applyCodeDiffButtons().should('have.length', 1);
aiAssistant.getters.applyCodeDiffButtons().first().click();
cy.wait('@applySuggestion');
aiAssistant.getters.applyCodeDiffButtons().should('have.length', 0);
aiAssistant.getters.undoReplaceCodeButtons().should('have.length', 1);
aiAssistant.getters.codeReplacedMessage().should('be.visible');
ndv.getters
.parameterInput('jsCode')
.get('.cm-content')
.should('contain.text', 'item.json.myNewField = 1');
// Clicking undo should revert the code back but not call the assistant
aiAssistant.getters.undoReplaceCodeButtons().first().click();
aiAssistant.getters.applyCodeDiffButtons().should('have.length', 1);
aiAssistant.getters.codeReplacedMessage().should('not.exist');
cy.get('@applySuggestion.all').then((interceptions) => {
expect(interceptions).to.have.length(1);
});
ndv.getters
.parameterInput('jsCode')
.get('.cm-content')
.should('contain.text', 'item.json.myNewField = 1aaa');
// Replacing the code again should also not call the assistant
cy.get('@applySuggestion.all').then((interceptions) => {
expect(interceptions).to.have.length(1);
});
aiAssistant.getters.applyCodeDiffButtons().should('have.length', 1);
aiAssistant.getters.applyCodeDiffButtons().first().click();
ndv.getters
.parameterInput('jsCode')
.get('.cm-content')
.should('contain.text', 'item.json.myNewField = 1');
});
it('should end chat session when `end_session` event is received', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
statusCode: 200,
fixture: 'aiAssistant/end_session_response.json',
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
wf.actions.openNode('Stop and Error');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
cy.wait('@chatRequest');
aiAssistant.getters.chatMessagesSystem().should('have.length', 1);
aiAssistant.getters.chatMessagesSystem().first().should('contain.text', 'session has ended');
});
});

View file

@ -0,0 +1,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');
});
});

View 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
}
]
]
}
}
}

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

View file

@ -0,0 +1,8 @@
{
"data": {
"sessionId": "f9130bd7-c078-4862-a38a-369b27b0ff20-e96eb9f7-d581-4684-b6a9-fd3dfe9fe1fb-emTezIGat7bQsDdtIlbti",
"parameters": {
"jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1;\n}\n\nreturn $input.all();"
}
}
}

View file

@ -0,0 +1,23 @@
{
"sessionId": "1",
"messages": [
{
"role": "assistant",
"type": "message",
"text": "Hi there! Here is my top solution to fix the error in your **Code** node 👇"
},
{
"type": "code-diff",
"description": "Fix the syntax error by changing '1asd' to a valid value. In this case, it seems like '1' was intended.",
"suggestionId": "1",
"codeDiff": "@@ -2,2 +2,2 @@\n item.json.myNewField = 1asd;\n+ item.json.myNewField = 1;\n",
"role": "assistant",
"quickReplies": [
{
"text": "Give me another solution",
"type": "new-suggestion"
}
]
}
]
}

View file

@ -0,0 +1,16 @@
{
"sessionId": "f9130bd7-c078-4862-a38a-369b27b0ff20-e96eb9f7-d581-4684-b6a9-fd3dfe9fe1fb-XCldJLlusGrEVku5I9cYT",
"messages": [
{
"role": "assistant",
"type": "agent-suggestion",
"title": "Glad to Help",
"text": "I'm glad I could help. If you have any more questions or need further assistance with your n8n workflows, feel free to ask!"
},
{
"role": "assistant",
"type": "event",
"eventName": "end-session"
}
]
}

View file

@ -0,0 +1,20 @@
{
"sessionId": "1",
"messages": [
{
"role": "assistant",
"type": "message",
"text": "Hey, this is an assistant message",
"quickReplies": [
{
"text": "Sure, let's do it",
"type": "yes"
},
{
"text": "Nah, doesn't sound good",
"type": "no"
}
]
}
]
}

View file

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

View file

@ -0,0 +1,88 @@
{
"nodes": [
{
"parameters": {},
"id": "ebfced75-2ce1-4c41-a971-6c3b83522c4d",
"name": "When clicking Test workflow",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
360,
220
]
},
{
"parameters": {
"errorMessage": "This is an error message"
},
"id": "f2e60459-401a-49d5-acfc-7b2b31cfdcf7",
"name": "Stop and Error",
"type": "n8n-nodes-base.stopAndError",
"typeVersion": 1,
"position": [
1020,
220
]
},
{
"parameters": {
"jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1aaa;\n}\n\nreturn $input.all();"
},
"id": "b54d4db9-b257-41a8-862f-26d293115bad",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
840,
320
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "053ada73-f7db-4e6a-8cc8-85756cc6ca4e",
"name": "age",
"value": "={{ 32sad }}",
"type": "number"
}
]
},
"options": {}
},
"id": "5fd89612-a871-4679-b7b0-d659e09c6a0e",
"name": "Edit Fields",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
600,
100
]
}
],
"connections": {
"When clicking Test workflow": {
"main": [
[
{
"node": "Stop and Error",
"type": "main",
"index": 0
},
{
"node": "Code",
"type": "main",
"index": 0
},
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {}
}

View file

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

View file

@ -0,0 +1,49 @@
import { overrideFeatureFlag } from '../../composables/featureFlags';
import { BasePage } from '../base';
const AI_ASSISTANT_FEATURE = {
name: 'aiAssistant',
experimentName: '021_ai_debug_helper',
enabledFor: 'variant',
disabledFor: 'control',
};
export class AIAssistant extends BasePage {
url = '/workflows/new';
getters = {
askAssistantFloatingButton: () => cy.getByTestId('ask-assistant-floating-button'),
askAssistantSidebar: () => cy.getByTestId('ask-assistant-sidebar'),
askAssistantSidebarResizer: () =>
this.getters.askAssistantSidebar().find('[class^=_resizer][data-dir=left]').first(),
askAssistantChat: () => cy.getByTestId('ask-assistant-chat'),
placeholderMessage: () => cy.getByTestId('placeholder-message'),
closeChatButton: () => cy.getByTestId('close-chat-button'),
chatInputWrapper: () => cy.getByTestId('chat-input-wrapper'),
chatInput: () => cy.getByTestId('chat-input'),
sendMessageButton: () => cy.getByTestId('send-message-button'),
chatMessagesAll: () => cy.get('[data-test-id^=chat-message]'),
chatMessagesAssistant: () => cy.getByTestId('chat-message-assistant'),
chatMessagesUser: () => cy.getByTestId('chat-message-user'),
chatMessagesSystem: () => cy.getByTestId('chat-message-system'),
quickReplies: () => cy.getByTestId('quick-replies').find('button'),
newAssistantSessionModal: () => cy.getByTestId('new-assistant-session-modal'),
codeDiffs: () => cy.getByTestId('code-diff-suggestion'),
applyCodeDiffButtons: () => cy.getByTestId('replace-code-button'),
undoReplaceCodeButtons: () => cy.getByTestId('undo-replace-button'),
codeReplacedMessage: () => cy.getByTestId('code-replaced-message'),
nodeErrorViewAssistantButton: () =>
cy.getByTestId('node-error-view-ask-assistant-button').find('button').first(),
};
actions = {
enableAssistant(): void {
overrideFeatureFlag(AI_ASSISTANT_FEATURE.experimentName, AI_ASSISTANT_FEATURE.enabledFor);
cy.enableFeature(AI_ASSISTANT_FEATURE.name);
},
disableAssistant(): void {
overrideFeatureFlag(AI_ASSISTANT_FEATURE.experimentName, AI_ASSISTANT_FEATURE.disabledFor);
cy.disableFeature(AI_ASSISTANT_FEATURE.name);
},
};
}

View file

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

View file

@ -17,6 +17,8 @@ beforeEach(() => {
cy.window().then((win): void => {
win.localStorage.setItem('N8N_THEME', 'light');
win.localStorage.setItem('N8N_AUTOCOMPLETE_ONBOARDED', 'true');
win.localStorage.setItem('N8N_MAPPING_ONBOARDED', 'true');
});
cy.intercept('GET', '/rest/settings', (req) => {

View file

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

View file

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

View file

@ -0,0 +1,62 @@
# syntax=docker/dockerfile:1
FROM node:20.16.0 AS base
# Install required dependencies
RUN apt-get update && apt-get install -y gnupg2 curl
# Add k6 GPG key and repository
RUN mkdir -p /etc/apt/keyrings && \
curl -sS https://dl.k6.io/key.gpg | gpg --dearmor --yes -o /etc/apt/keyrings/k6.gpg && \
chmod a+x /etc/apt/keyrings/k6.gpg && \
echo "deb [signed-by=/etc/apt/keyrings/k6.gpg] https://dl.k6.io/deb stable main" | tee /etc/apt/sources.list.d/k6.list
# Update and install k6
RUN apt-get update && \
apt-get install -y k6 tini && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
#
# Builder
FROM base AS builder
WORKDIR /app
COPY --chown=node:node ./pnpm-lock.yaml /app/pnpm-lock.yaml
COPY --chown=node:node ./pnpm-workspace.yaml /app/pnpm-workspace.yaml
COPY --chown=node:node ./package.json /app/package.json
COPY --chown=node:node ./packages/@n8n/benchmark/package.json /app/packages/@n8n/benchmark/package.json
COPY --chown=node:node ./patches /app/patches
COPY --chown=node:node ./scripts /app/scripts
RUN pnpm install --frozen-lockfile
# TS config files
COPY --chown=node:node ./tsconfig.json /app/tsconfig.json
COPY --chown=node:node ./tsconfig.build.json /app/tsconfig.build.json
COPY --chown=node:node ./tsconfig.backend.json /app/tsconfig.backend.json
COPY --chown=node:node ./packages/@n8n/benchmark/tsconfig.json /app/packages/@n8n/benchmark/tsconfig.json
COPY --chown=node:node ./packages/@n8n/benchmark/tsconfig.build.json /app/packages/@n8n/benchmark/tsconfig.build.json
# Source files
COPY --chown=node:node ./packages/@n8n/benchmark/src /app/packages/@n8n/benchmark/src
COPY --chown=node:node ./packages/@n8n/benchmark/bin /app/packages/@n8n/benchmark/bin
COPY --chown=node:node ./packages/@n8n/benchmark/scenarios /app/packages/@n8n/benchmark/scenarios
WORKDIR /app/packages/@n8n/benchmark
RUN pnpm build
#
# Runner
FROM base AS runner
COPY --from=builder /app /app
WORKDIR /app/packages/@n8n/benchmark
USER node
ENTRYPOINT [ "/app/packages/@n8n/benchmark/bin/n8n-benchmark" ]

View file

@ -0,0 +1,55 @@
# n8n benchmarking tool
Tool for executing benchmarks against an n8n instance.
## Running locally with Docker
Build the Docker image:
```sh
# Must be run in the repository root
# k6 doesn't have an arm64 build available for linux, we need to build against amd64
docker build --platform linux/amd64 -t n8n-benchmark -f packages/@n8n/benchmark/Dockerfile .
```
Run the image
```sh
docker run \
-e N8N_USER_EMAIL=user@n8n.io \
-e N8N_USER_PASSWORD=password \
# For macos, n8n running outside docker
-e N8N_BASE_URL=http://host.docker.internal:5678 \
n8n-benchmark
```
## Running locally without Docker
Requirements:
- [k6](https://grafana.com/docs/k6/latest/set-up/install-k6/)
- Node.js v20 or higher
```sh
pnpm build
# Run tests against http://localhost:5678 with specified email and password
N8N_USER_EMAIL=user@n8n.io N8N_USER_PASSWORD=password ./bin/n8n-benchmark run
# If you installed k6 using brew, you might have to specify it explicitly
K6_PATH=/opt/homebrew/bin/k6 N8N_USER_EMAIL=user@n8n.io N8N_USER_PASSWORD=password ./bin/n8n-benchmark run
```
## Configuration
The configuration options the cli accepts can be seen from [config.ts](./src/config/config.ts)
## Benchmark scenarios
A benchmark scenario defines one or multiple steps to execute and measure. It consists of:
- Manifest file which describes and configures the scenario
- Any test data that is imported before the scenario is run
- A [`k6`](https://grafana.com/docs/k6/latest/using-k6/http-requests/) script which executes the steps and receives `API_BASE_URL` environment variable in runtime.
Available scenarios are located in [`./scenarios`](./scenarios/).

View file

@ -0,0 +1,13 @@
#!/usr/bin/env node
// Check if version should be displayed
const versionFlags = ['-v', '-V', '--version'];
if (versionFlags.includes(process.argv.slice(-1)[0])) {
console.log(require('../package').version);
process.exit(0);
}
(async () => {
const oclif = require('@oclif/core');
await oclif.execute({ dir: __dirname });
})();

View file

@ -0,0 +1,48 @@
{
"name": "@n8n/n8n-benchmark",
"version": "1.0.0",
"description": "Cli for running benchmark tests for n8n",
"main": "dist/index",
"scripts": {
"build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
"start": "./bin/n8n-benchmark",
"test": "echo \"Error: no test specified\" && exit 1",
"typecheck": "tsc --noEmit",
"watch": "concurrently \"tsc -w -p tsconfig.build.json\" \"tsc-alias -w -p tsconfig.build.json\""
},
"engines": {
"node": ">=20.10"
},
"keywords": [
"automate",
"automation",
"IaaS",
"iPaaS",
"n8n",
"workflow",
"benchmark",
"performance"
],
"dependencies": {
"@oclif/core": "4.0.7",
"axios": "catalog:",
"convict": "6.2.4",
"dotenv": "8.6.0",
"zx": "^8.1.4"
},
"devDependencies": {
"@types/convict": "^6.1.1",
"@types/k6": "^0.52.0",
"@types/node": "^20.14.8",
"tsc-alias": "^1.8.7",
"typescript": "^5.5.2"
},
"bin": {
"n8n-benchmark": "./bin/n8n-benchmark"
},
"oclif": {
"bin": "n8n-benchmark",
"commands": "./dist/commands",
"topicSeparator": " "
}
}

View file

@ -0,0 +1,42 @@
{
"definitions": {
"ScenarioData": {
"type": "object",
"properties": {
"workflowFiles": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [],
"additionalProperties": false
}
},
"type": "object",
"properties": {
"$schema": {
"type": "string",
"description": "The JSON schema to validate this file"
},
"name": {
"type": "string",
"description": "The name of the scenario"
},
"description": {
"type": "string",
"description": "A longer description of the scenario"
},
"scriptPath": {
"type": "string",
"description": "Relative path to the k6 test script"
},
"scenarioData": {
"$ref": "#/definitions/ScenarioData",
"description": "Data to import before running the scenario"
}
},
"required": ["name", "description", "scriptPath", "scenarioData"],
"additionalProperties": false
}

View file

@ -0,0 +1,25 @@
{
"createdAt": "2024-08-06T12:19:51.268Z",
"updatedAt": "2024-08-06T12:20:45.000Z",
"name": "Single Webhook",
"active": true,
"nodes": [
{
"parameters": { "path": "single-webhook", "options": {} },
"id": "7587ab0e-cc15-424f-83c0-c887a0eb97fb",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [760, 400],
"webhookId": "fa563fc2-c73f-4631-99a1-39c16f1f858f"
}
],
"connections": {},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": { "templateCredsSetupCompleted": true, "responseMode": "lastNode", "options": {} },
"pinData": {},
"versionId": "840a38a1-ba37-433d-9f20-de73f5131a2b",
"triggerCount": 1,
"tags": []
}

View file

@ -0,0 +1,7 @@
{
"$schema": "../scenario.schema.json",
"name": "SingleWebhook",
"description": "A single webhook trigger that responds with a 200 status code",
"scenarioData": { "workflowFiles": ["singleWebhook.json"] },
"scriptPath": "singleWebhook.script.ts"
}

View file

@ -0,0 +1,11 @@
import http from 'k6/http';
import { check } from 'k6';
const apiBaseUrl = __ENV.API_BASE_URL;
export default function () {
const res = http.get(`${apiBaseUrl}/webhook/single-webhook`);
check(res, {
'is status 200': (r) => r.status === 200,
});
}

View file

@ -0,0 +1,21 @@
import { Command } from '@oclif/core';
import { ScenarioLoader } from '@/scenario/scenarioLoader';
import { loadConfig } from '@/config/config';
export default class ListCommand extends Command {
static description = 'List all available scenarios';
async run() {
const config = loadConfig();
const scenarioLoader = new ScenarioLoader();
const allScenarios = scenarioLoader.loadAll(config.get('testScenariosPath'));
console.log('Available test scenarios:');
console.log('');
for (const scenario of allScenarios) {
console.log('\t', scenario.name, ':', scenario.description);
}
}
}

View file

@ -0,0 +1,39 @@
import { Command, Flags } from '@oclif/core';
import { loadConfig } from '@/config/config';
import { ScenarioLoader } from '@/scenario/scenarioLoader';
import { ScenarioRunner } from '@/testExecution/scenarioRunner';
import { N8nApiClient } from '@/n8nApiClient/n8nApiClient';
import { ScenarioDataFileLoader } from '@/scenario/scenarioDataLoader';
import { K6Executor } from '@/testExecution/k6Executor';
export default class RunCommand extends Command {
static description = 'Run all (default) or specified test scenarios';
// TODO: Add support for filtering scenarios
static flags = {
scenarios: Flags.string({
char: 't',
description: 'Comma-separated list of test scenarios to run',
required: false,
}),
};
async run() {
const config = loadConfig();
const scenarioLoader = new ScenarioLoader();
const scenarioRunner = new ScenarioRunner(
new N8nApiClient(config.get('n8n.baseUrl')),
new ScenarioDataFileLoader(),
new K6Executor(config.get('k6ExecutablePath'), config.get('n8n.baseUrl')),
{
email: config.get('n8n.user.email'),
password: config.get('n8n.user.password'),
},
);
const allScenarios = scenarioLoader.loadAll(config.get('testScenariosPath'));
await scenarioRunner.runManyScenarios(allScenarios);
}
}

View file

@ -0,0 +1,50 @@
import convict from 'convict';
import dotenv from 'dotenv';
dotenv.config();
const configSchema = {
testScenariosPath: {
doc: 'The path to the scenarios',
format: String,
default: 'scenarios',
},
n8n: {
baseUrl: {
doc: 'The base URL for the n8n instance',
format: String,
default: 'http://localhost:5678',
env: 'N8N_BASE_URL',
},
user: {
email: {
doc: 'The email address of the n8n user',
format: String,
default: 'benchmark-user@n8n.io',
env: 'N8N_USER_EMAIL',
},
password: {
doc: 'The password of the n8n user',
format: String,
default: 'VerySecret!123',
env: 'N8N_USER_PASSWORD',
},
},
},
k6ExecutablePath: {
doc: 'The path to the k6 binary',
format: String,
default: 'k6',
env: 'K6_PATH',
},
};
export type Config = ReturnType<typeof loadConfig>;
export function loadConfig() {
const config = convict(configSchema);
config.validate({ allowed: 'strict' });
return config;
}

View file

@ -0,0 +1,67 @@
import { strict as assert } from 'node:assert';
import { N8nApiClient } from './n8nApiClient';
import { AxiosRequestConfig } from 'axios';
export class AuthenticatedN8nApiClient extends N8nApiClient {
constructor(
apiBaseUrl: string,
private readonly authCookie: string,
) {
super(apiBaseUrl);
}
static async createUsingUsernameAndPassword(
apiClient: N8nApiClient,
loginDetails: {
email: string;
password: string;
},
) {
const response = await apiClient.restApiRequest('/login', {
method: 'POST',
data: loginDetails,
});
const cookieHeader = response.headers['set-cookie'];
const authCookie = Array.isArray(cookieHeader) ? cookieHeader.join('; ') : cookieHeader;
assert(authCookie);
return new AuthenticatedN8nApiClient(apiClient.apiBaseUrl, authCookie);
}
async get<T>(endpoint: string) {
return await this.authenticatedRequest<T>(endpoint, {
method: 'GET',
});
}
async post<T>(endpoint: string, data: unknown) {
return await this.authenticatedRequest<T>(endpoint, {
method: 'POST',
data,
});
}
async patch<T>(endpoint: string, data: unknown) {
return await this.authenticatedRequest<T>(endpoint, {
method: 'PATCH',
data,
});
}
async delete<T>(endpoint: string) {
return await this.authenticatedRequest<T>(endpoint, {
method: 'DELETE',
});
}
protected async authenticatedRequest<T>(endpoint: string, init: Omit<AxiosRequestConfig, 'url'>) {
return await this.restApiRequest<T>(endpoint, {
...init,
headers: {
...init.headers,
cookie: this.authCookie,
},
});
}
}

View file

@ -0,0 +1,78 @@
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
export class N8nApiClient {
constructor(public readonly apiBaseUrl: string) {}
async waitForInstanceToBecomeOnline(): Promise<void> {
const HEALTH_ENDPOINT = 'healthz';
const START_TIME = Date.now();
const INTERVAL_MS = 1000;
const TIMEOUT_MS = 60_000;
while (Date.now() - START_TIME < TIMEOUT_MS) {
try {
const response = await axios.request({
url: `${this.apiBaseUrl}/${HEALTH_ENDPOINT}`,
method: 'GET',
});
if (response.status === 200 && response.data.status === 'ok') {
return;
}
} catch {}
console.log(`n8n instance not online yet, retrying in ${INTERVAL_MS / 1000} seconds...`);
await this.delay(INTERVAL_MS);
}
throw new Error(`n8n instance did not come online within ${TIMEOUT_MS / 1000} seconds`);
}
async setupOwnerIfNeeded(loginDetails: { email: string; password: string }) {
const response = await this.restApiRequest<{ message: string }>('/owner/setup', {
method: 'POST',
data: {
email: loginDetails.email,
password: loginDetails.password,
firstName: 'Test',
lastName: 'User',
},
// Don't throw on non-2xx responses
validateStatus: () => true,
});
const responsePayload = response.data;
if (response.status === 200) {
console.log('Owner setup successful');
} else if (response.status === 400) {
if (responsePayload.message === 'Instance owner already setup')
console.log('Owner already set up');
} else {
throw new Error(
`Owner setup failed with status ${response.status}: ${responsePayload.message}`,
);
}
}
async restApiRequest<T>(endpoint: string, init: Omit<AxiosRequestConfig, 'url'>) {
try {
return await axios.request<T>({
...init,
url: this.getRestEndpointUrl(endpoint),
});
} catch (e) {
const error = e as AxiosError;
console.error(`[ERROR] Request failed ${init.method} ${endpoint}`, error?.response?.data);
throw error;
}
}
protected getRestEndpointUrl(endpoint: string) {
return `${this.apiBaseUrl}/rest${endpoint}`;
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View file

@ -0,0 +1,8 @@
/**
* n8n workflow. This is a simplified version of the actual workflow object.
*/
export type Workflow = {
id: string;
name: string;
tags?: string[];
};

View file

@ -0,0 +1,31 @@
import { Workflow } from '@/n8nApiClient/n8nApiClient.types';
import { AuthenticatedN8nApiClient } from './authenticatedN8nApiClient';
export class WorkflowApiClient {
constructor(private readonly apiClient: AuthenticatedN8nApiClient) {}
async getAllWorkflows(): Promise<Workflow[]> {
const response = await this.apiClient.get<{ count: number; data: Workflow[] }>('/workflows');
return response.data.data;
}
async createWorkflow(workflow: unknown): Promise<Workflow> {
const response = await this.apiClient.post<{ data: Workflow }>('/workflows', workflow);
return response.data.data;
}
async activateWorkflow(workflow: Workflow): Promise<Workflow> {
const response = await this.apiClient.patch<{ data: Workflow }>(`/workflows/${workflow.id}`, {
...workflow,
active: true,
});
return response.data.data;
}
async deleteWorkflow(workflowId: Workflow['id']): Promise<void> {
await this.apiClient.delete(`/workflows/${workflowId}`);
}
}

View file

@ -0,0 +1,35 @@
import fs from 'node:fs';
import path from 'node:path';
import { Scenario } from '@/types/scenario';
import { Workflow } from '@/n8nApiClient/n8nApiClient.types';
/**
* Loads scenario data files from FS
*/
export class ScenarioDataFileLoader {
async loadDataForScenario(scenario: Scenario): Promise<{
workflows: Workflow[];
}> {
const workflows = await Promise.all(
scenario.scenarioData.workflowFiles?.map((workflowFilePath) =>
this.loadSingleWorkflowFromFile(path.join(scenario.scenarioDirPath, workflowFilePath)),
) ?? [],
);
return {
workflows,
};
}
private loadSingleWorkflowFromFile(workflowFilePath: string): Workflow {
const fileContent = fs.readFileSync(workflowFilePath, 'utf8');
try {
return JSON.parse(fileContent);
} catch (error) {
throw new Error(
`Failed to parse workflow file ${workflowFilePath}: ${error instanceof Error ? error.message : error}`,
);
}
}
}

View file

@ -0,0 +1,67 @@
import * as fs from 'node:fs';
import * as path from 'path';
import { createHash } from 'node:crypto';
import type { Scenario, ScenarioManifest } from '@/types/scenario';
export class ScenarioLoader {
/**
* Loads all scenarios from the given path
*/
loadAll(pathToScenarios: string): Scenario[] {
pathToScenarios = path.resolve(pathToScenarios);
const scenarioFolders = fs
.readdirSync(pathToScenarios, { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name);
const scenarios: Scenario[] = [];
for (const folder of scenarioFolders) {
const scenarioPath = path.join(pathToScenarios, folder);
const manifestFileName = `${folder}.manifest.json`;
const scenarioManifestPath = path.join(pathToScenarios, folder, manifestFileName);
if (!fs.existsSync(scenarioManifestPath)) {
console.warn(`Scenario at ${scenarioPath} is missing the ${manifestFileName} file`);
continue;
}
// Load the scenario manifest file
const [scenario, validationErrors] =
this.loadAndValidateScenarioManifest(scenarioManifestPath);
if (validationErrors) {
console.warn(
`Scenario at ${scenarioPath} has the following validation errors: ${validationErrors.join(', ')}`,
);
continue;
}
scenarios.push({
...scenario,
id: this.formScenarioId(scenarioPath),
scenarioDirPath: scenarioPath,
});
}
return scenarios;
}
private loadAndValidateScenarioManifest(
scenarioManifestPath: string,
): [ScenarioManifest, null] | [null, string[]] {
const scenario = JSON.parse(fs.readFileSync(scenarioManifestPath, 'utf8'));
const validationErrors: string[] = [];
if (!scenario.name) {
validationErrors.push(`Scenario at ${scenarioManifestPath} is missing a name`);
}
if (!scenario.description) {
validationErrors.push(`Scenario at ${scenarioManifestPath} is missing a description`);
}
return validationErrors.length === 0 ? [scenario, null] : [null, validationErrors];
}
private formScenarioId(scenarioPath: string): string {
return createHash('sha256').update(scenarioPath).digest('hex');
}
}

View file

@ -0,0 +1,28 @@
import { $ } from 'zx';
import { Scenario } from '@/types/scenario';
/**
* Executes test scenarios using k6
*/
export class K6Executor {
constructor(
private readonly k6ExecutablePath: string,
private readonly n8nApiBaseUrl: string,
) {}
async executeTestScenario(scenario: Scenario) {
// For 1 min with 5 virtual users
const stage = '1m:5';
const processPromise = $({
cwd: scenario.scenarioDirPath,
env: {
API_BASE_URL: this.n8nApiBaseUrl,
},
})`${this.k6ExecutablePath} run --quiet --stage ${stage} ${scenario.scriptPath}`;
for await (const chunk of processPromise.stdout) {
console.log(chunk.toString());
}
}
}

View file

@ -0,0 +1,56 @@
import { AuthenticatedN8nApiClient } from '@/n8nApiClient/authenticatedN8nApiClient';
import { Workflow } from '@/n8nApiClient/n8nApiClient.types';
import { WorkflowApiClient } from '@/n8nApiClient/workflowsApiClient';
/**
* Imports scenario data into an n8n instance
*/
export class ScenarioDataImporter {
private readonly workflowApiClient: WorkflowApiClient;
constructor(n8nApiClient: AuthenticatedN8nApiClient) {
this.workflowApiClient = new WorkflowApiClient(n8nApiClient);
}
async importTestScenarioData(workflows: Workflow[]) {
const existingWorkflows = await this.workflowApiClient.getAllWorkflows();
for (const workflow of workflows) {
await this.importWorkflow({ existingWorkflows, workflow });
}
}
/**
* Imports a single workflow into n8n removing any existing workflows with the same name
*/
private async importWorkflow(opts: { existingWorkflows: Workflow[]; workflow: Workflow }) {
const existingWorkflows = this.findExistingWorkflows(opts.existingWorkflows, opts.workflow);
if (existingWorkflows.length > 0) {
for (const toDelete of existingWorkflows) {
await this.workflowApiClient.deleteWorkflow(toDelete.id);
}
}
const createdWorkflow = await this.workflowApiClient.createWorkflow({
...opts.workflow,
name: this.getBenchmarkWorkflowName(opts.workflow),
});
return await this.workflowApiClient.activateWorkflow(createdWorkflow);
}
private findExistingWorkflows(
existingWorkflows: Workflow[],
workflowToImport: Workflow,
): Workflow[] {
const benchmarkWorkflowName = this.getBenchmarkWorkflowName(workflowToImport);
return existingWorkflows.filter(
(existingWorkflow) => existingWorkflow.name === benchmarkWorkflowName,
);
}
private getBenchmarkWorkflowName(workflow: Workflow) {
return `[BENCHMARK] ${workflow.name}`;
}
}

View file

@ -0,0 +1,50 @@
import { Scenario } from '@/types/scenario';
import { N8nApiClient } from '@/n8nApiClient/n8nApiClient';
import { ScenarioDataFileLoader } from '@/scenario/scenarioDataLoader';
import { K6Executor } from './k6Executor';
import { ScenarioDataImporter } from '@/testExecution/scenarioDataImporter';
import { AuthenticatedN8nApiClient } from '@/n8nApiClient/authenticatedN8nApiClient';
/**
* Runs scenarios
*/
export class ScenarioRunner {
constructor(
private readonly n8nClient: N8nApiClient,
private readonly dataLoader: ScenarioDataFileLoader,
private readonly k6Executor: K6Executor,
private readonly ownerConfig: {
email: string;
password: string;
},
) {}
async runManyScenarios(scenarios: Scenario[]) {
console.log(`Waiting for n8n ${this.n8nClient.apiBaseUrl} to become online`);
await this.n8nClient.waitForInstanceToBecomeOnline();
console.log('Setting up owner');
await this.n8nClient.setupOwnerIfNeeded(this.ownerConfig);
const authenticatedN8nClient = await AuthenticatedN8nApiClient.createUsingUsernameAndPassword(
this.n8nClient,
this.ownerConfig,
);
const testDataImporter = new ScenarioDataImporter(authenticatedN8nClient);
for (const scenario of scenarios) {
await this.runSingleTestScenario(testDataImporter, scenario);
}
}
private async runSingleTestScenario(testDataImporter: ScenarioDataImporter, scenario: Scenario) {
console.log('Running scenario:', scenario.name);
console.log('Loading and importing data');
const testData = await this.dataLoader.loadDataForScenario(scenario);
await testDataImporter.importTestScenarioData(testData.workflows);
console.log('Executing scenario script');
await this.k6Executor.executeTestScenario(scenario);
}
}

View file

@ -0,0 +1,27 @@
export type ScenarioData = {
/** Relative paths to the workflow files */
workflowFiles?: string[];
};
/**
* Configuration that defines the benchmark scenario
*/
export type ScenarioManifest = {
/** The name of the scenario */
name: string;
/** A longer description of the scenario */
description: string;
/** Relative path to the k6 script */
scriptPath: string;
/** Data to import before running the scenario */
scenarioData: ScenarioData;
};
/**
* Scenario with additional metadata
*/
export type Scenario = ScenarioManifest & {
id: string;
/** Path to the directory containing the scenario */
scenarioDirPath: string;
};

View file

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

View file

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

View file

@ -7,4 +7,13 @@ module.exports = {
extends: ['@n8n_io/eslint-config/node'],
...sharedOptions(__dirname),
overrides: [
{
files: ['**/*.config.ts'],
rules: {
'n8n-local-rules/no-untyped-config-class-field': 'error',
},
},
],
};

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/config",
"version": "1.5.0",
"version": "1.6.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,9 +4,9 @@ import { Config, Env } from '../decorators';
export class ExternalSecretsConfig {
/** How often (in seconds) to check for secret updates */
@Env('N8N_EXTERNAL_SECRETS_UPDATE_INTERVAL')
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;
}

View file

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

View file

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

View file

@ -4,13 +4,13 @@ import { Config, Env } from '../decorators';
export class PublicApiConfig {
/** Whether to disable the Public API */
@Env('N8N_PUBLIC_API_DISABLED')
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;
}

View file

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

View file

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

View file

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

View file

@ -4,13 +4,13 @@ import { Config, Env } from '../decorators';
export class VersionNotificationsConfig {
/** Whether to request notifications about new n8n versions */
@Env('N8N_VERSION_NOTIFICATIONS_ENABLED')
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/';
}

View file

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

View file

@ -47,6 +47,11 @@ export const Config: ClassDecorator = (ConfigClass: Class) => {
} else {
value = value === 'true';
}
} else if (type === Object) {
// eslint-disable-next-line n8n-local-rules/no-plain-errors
throw new Error(
`Invalid decorator metadata on key "${key as string}" on ${ConfigClass.name}\n Please use explicit typing on all config fields`,
);
} else if (type !== String && type !== Object) {
value = new (type as Constructable)(value as string);
}

View file

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

View file

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

View file

@ -9,7 +9,6 @@ import type {
INodeTypeDescription,
INodeProperties,
} from 'n8n-workflow';
import { getTemplateNoticeField } from '../../../utils/sharedFields';
import { promptTypeOptions, textInput } from '../../../utils/descriptions';
import { conversationalAgentProperties } from './agents/ConversationalAgent/description';
import { conversationalAgentExecute } from './agents/ConversationalAgent/execute';
@ -83,6 +82,7 @@ function getInputs(
filter: {
nodes: [
'@n8n/n8n-nodes-langchain.lmChatAnthropic',
'@n8n/n8n-nodes-langchain.lmChatAwsBedrock',
'@n8n/n8n-nodes-langchain.lmChatGroq',
'@n8n/n8n-nodes-langchain.lmChatOllama',
'@n8n/n8n-nodes-langchain.lmChatOpenAi',
@ -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'],
},
},
},

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/n8n-nodes-langchain",
"version": "1.55.0",
"version": "1.56.0",
"description": "",
"main": "index.js",
"scripts": {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +0,0 @@
import { UserManagementMailer } from './UserManagementMailer';
export { UserManagementMailer };

View file

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