Merge remote-tracking branch 'origin/master' into sec-108-extract-api-keys-away-from-users-table

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-09-17 18:00:13 +02:00
commit 61f0ee627a
No known key found for this signature in database
GPG key ID: 9300FF7CDEA1FBAA
1622 changed files with 120804 additions and 13532 deletions

View file

@ -9,7 +9,7 @@
"type=bind,source=${localEnv:HOME}/.n8n,target=/home/node/.n8n,consistency=cached"
],
"forwardPorts": [8080, 5678],
"postCreateCommand": "corepack prepare --activate && pnpm install ",
"postCreateCommand": "corepack prepare --activate && pnpm install",
"postAttachCommand": "pnpm build",
"customizations": {
"codespaces": {

View file

@ -13,7 +13,7 @@ A PR title consists of these elements:
│ │
│ └─⫸ Scope: API | benchmark | core | editor | * Node
└─⫸ Type: build | ci | docs | feat | fix | perf | refactor | test
└─⫸ Type: build | ci | chore | docs | feat | fix | perf | refactor | test
```
- PR title
@ -41,6 +41,7 @@ Must be one of the following:
| `refactor` | A behavior-neutral code change that neither fixes a bug nor adds a feature | ❌ |
| `build` | Changes that affect the build system or external dependencies (TypeScript, Jest, pnpm, etc.) | ❌ |
| `ci` | Changes to CI configuration files and scripts (e.g. Github actions) | ❌ |
| `chore` | Routine tasks, maintenance, and minor updates not covered by other types | ❌ |
> BREAKING CHANGES (see Footer section below), will **always** appear in the changelog unless suffixed with `no-changelog`.

View file

@ -3,7 +3,7 @@ run-name: Benchmark ${{ inputs.n8n_tag || 'nightly' }}
on:
schedule:
- cron: '0 2 * * *'
- cron: '30 1,2,3 * * *'
workflow_dispatch:
inputs:
debug:
@ -23,7 +23,9 @@ env:
ARM_CLIENT_ID: ${{ secrets.BENCHMARK_ARM_CLIENT_ID }}
ARM_SUBSCRIPTION_ID: ${{ secrets.BENCHMARK_ARM_SUBSCRIPTION_ID }}
ARM_TENANT_ID: ${{ secrets.BENCHMARK_ARM_TENANT_ID }}
K6_API_TOKEN: ${{ secrets.K6_API_TOKEN }}
N8N_TAG: ${{ inputs.n8n_tag || 'nightly' }}
N8N_BENCHMARK_TAG: ${{ inputs.benchmark_tag || 'latest' }}
DEBUG: ${{ inputs.debug == 'true' && '--debug' || '' }}
permissions:
id-token: write
@ -62,12 +64,34 @@ jobs:
run: pnpm destroy-cloud-env
working-directory: packages/@n8n/benchmark
- name: Run the benchmark with debug logging
if: github.event.inputs.debug == 'true'
run: pnpm benchmark-in-cloud --n8nTag ${{ inputs.n8n_tag || 'nightly' }} --benchmarkTag ${{ inputs.benchmark_tag || 'latest' }} --debug
- name: Provision the environment
run: pnpm provision-cloud-env ${{ env.DEBUG }}
working-directory: packages/@n8n/benchmark
- name: Run the benchmark
if: github.event.inputs.debug != 'true'
run: pnpm benchmark-in-cloud --n8nTag ${{ inputs.n8n_tag || 'nightly' }} --benchmarkTag ${{ inputs.benchmark_tag || 'latest' }}
env:
BENCHMARK_RESULT_WEBHOOK_URL: ${{ secrets.BENCHMARK_RESULT_WEBHOOK_URL }}
BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER: ${{ secrets.BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER }}
N8N_LICENSE_CERT: ${{ secrets.N8N_BENCHMARK_LICENSE_CERT }}
run: |
pnpm benchmark-in-cloud \
--vus 5 \
--duration 1m \
--n8nTag ${{ env.N8N_TAG }} \
--benchmarkTag ${{ env.N8N_BENCHMARK_TAG }} \
${{ env.DEBUG }}
working-directory: packages/@n8n/benchmark
# We need to login again because the access token expires
- name: Azure login
if: always()
uses: azure/login@v2.1.1
with:
client-id: ${{ env.ARM_CLIENT_ID }}
tenant-id: ${{ env.ARM_TENANT_ID }}
subscription-id: ${{ env.ARM_SUBSCRIPTION_ID }}
- name: Destroy the environment
if: always()
run: pnpm destroy-cloud-env ${{ env.DEBUG }}
working-directory: packages/@n8n/benchmark

View file

@ -26,7 +26,7 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Build relevant packages
run: pnpm --filter @n8n/client-oauth2 --filter @n8n/imap --filter n8n-workflow --filter n8n-core --filter n8n-nodes-base --filter @n8n/n8n-nodes-langchain build
run: pnpm build:nodes
- run: npm install --prefix=.github/scripts --no-package-lock

View file

@ -28,6 +28,6 @@ jobs:
- name: Validate PR title
id: validate_pr_title
uses: n8n-io/validate-n8n-pull-request-title@v2.0.1
uses: n8n-io/validate-n8n-pull-request-title@v2.2.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -4,19 +4,49 @@ on:
workflow_dispatch:
pull_request_review:
types: [submitted]
branches:
- 'master'
paths:
- packages/design-system/**
- .github/workflows/chromatic.yml
concurrency:
group: chromatic-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
get-metadata:
name: Get Metadata
runs-on: ubuntu-latest
steps:
- name: Check out current commit
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 2
- name: Determine changed files
uses: tomi/paths-filter-action@v3.0.2
id: changed
if: github.event_name == 'pull_request_review'
with:
filters: |
design_system:
- packages/design-system/**
- .github/workflows/chromatic.yml
outputs:
design_system_files_changed: ${{ steps.changed.outputs.design_system == 'true' }}
is_community_pr: ${{ contains(github.event.pull_request.labels.*.name, 'community') }}
is_pr_target_master: ${{ github.event.pull_request.base.ref == 'master' }}
is_dispatch: ${{ github.event_name == 'workflow_dispatch' }}
is_pr_approved: ${{ github.event.review.state == 'approved' }}
chromatic:
if: ${{ github.event.review.state == 'approved' && !contains(github.event.pull_request.labels.*.name, 'community') }}
needs: [get-metadata]
if: |
needs.get-metadata.outputs.is_dispatch == 'true' ||
(
needs.get-metadata.outputs.design_system_files_changed == 'true' &&
needs.get-metadata.outputs.is_community_pr == 'false' &&
needs.get-metadata.outputs.is_pr_target_master == 'true' &&
needs.get-metadata.outputs.is_pr_approved == 'true'
)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.1.1

View file

@ -10,8 +10,6 @@ on:
- .github/workflows/ci-postgres-mysql.yml
pull_request_review:
types: [submitted]
branches:
- 'release/*'
concurrency:
group: db-${{ github.event.pull_request.number || github.ref }}
@ -21,6 +19,7 @@ jobs:
build:
name: Install & Build
runs-on: ubuntu-latest
if: github.event_name != 'pull_request_review' || startsWith(github.event.pull_request.base.ref, 'release/')
steps:
- uses: actions/checkout@v4.1.1
- run: corepack enable

View file

@ -30,6 +30,9 @@ jobs:
- name: Build
run: pnpm build
- name: Run formatcheck
run: pnpm format:check
- name: Run typecheck
run: pnpm typecheck

View file

@ -3,19 +3,51 @@ name: PR E2E
on:
pull_request_review:
types: [submitted]
branches:
- 'master'
- 'release/*'
concurrency:
group: e2e-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
get-metadata:
name: Get Metadata
runs-on: ubuntu-latest
steps:
- name: Check out current commit
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 2
- name: Determine changed files
uses: tomi/paths-filter-action@v3.0.2
id: changed
with:
filters: |
not_ignored:
- '!.devcontainer/**'
- '!.github/*'
- '!.github/scripts/*'
- '!.github/workflows/benchmark-*'
- '!.github/workflows/check-*'
- '!.vscode/**'
- '!docker/**'
- '!packages/@n8n/benchmark/**'
- '!**/*.md'
predicate-quantifier: 'every'
outputs:
# The workflow should run when:
# - It has changes to files that are not ignored
# - It is not a community PR
# - It is targeting master or a release branch
should_run: ${{ steps.changed.outputs.not_ignored == 'true' && !contains(github.event.pull_request.labels.*.name, 'community') && (github.event.pull_request.base.ref == 'master' || startsWith(github.event.pull_request.base.ref, 'release/')) }}
run-e2e-tests:
name: E2E [Electron/Node 18]
uses: ./.github/workflows/e2e-reusable.yml
if: ${{ github.event.review.state == 'approved' && !contains(github.event.pull_request.labels.*.name, 'community') }}
needs: [get-metadata]
if: ${{ github.event.review.state == 'approved' && needs.get-metadata.outputs.should_run == 'true' }}
with:
pr_number: ${{ github.event.pull_request.number }}
user: ${{ github.event.pull_request.user.login || 'PR User' }}
@ -25,11 +57,11 @@ jobs:
post-e2e-tests:
runs-on: ubuntu-latest
name: E2E [Electron/Node 18] - Checks
needs: [run-e2e-tests]
needs: [get-metadata, run-e2e-tests]
if: always()
steps:
- name: E2E success comment
if: ${{!contains(github.event.pull_request.labels.*.name, 'community') && needs.run-e2e-tests.outputs.tests_passed == 'true' }}
if: ${{ needs.get-metadata.outputs.should_run == 'true' && needs.run-e2e-tests.outputs.tests_passed == 'true' }}
uses: peter-evans/create-or-update-comment@v4.0.0
with:
issue-number: ${{ github.event.pull_request.number }}

View file

@ -7,3 +7,11 @@ packages/nodes-base/nodes/**/test
packages/cli/templates/form-trigger.handlebars
cypress/fixtures
CHANGELOG.md
.github/pull_request_template.md
# Ignored for now
**/*.md
# Handled by biome
**/*.ts
**/*.js
**/*.json
**/*.jsonc

View file

@ -1,5 +1,6 @@
{
"recommendations": [
"biomejs.biome",
"streetsidesoftware.code-spell-checker",
"dangmai.workspace-default-settings",
"dbaeumer.vscode-eslint",

View file

@ -1,6 +1,22 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[json]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[jsonc]": {
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"quickfix.biome": "explicit",
"source.organizeImports.biome": "never"
},
"search.exclude": {
"node_modules": true,
"dist": true,

View file

@ -1,3 +1,91 @@
# [1.59.0](https://github.com/n8n-io/n8n/compare/n8n@1.58.0...n8n@1.59.0) (2024-09-11)
### Bug Fixes
* **Chat Trigger Node:** Fix auth in "Embedded Chat" mode ([#10734](https://github.com/n8n-io/n8n/issues/10734)) ([96db501](https://github.com/n8n-io/n8n/commit/96db501a615ff7ec91bb66ea49532a2c6ca2a172))
* **core:** Allow license:clear command to be used for licenses that failed renewal ([#10665](https://github.com/n8n-io/n8n/issues/10665)) ([a422c5a](https://github.com/n8n-io/n8n/commit/a422c5ac7b8f609eeab891230d9660f71bf225c5))
* **core:** Update subworkflow execution status correctly ([#10764](https://github.com/n8n-io/n8n/issues/10764)) ([4f94319](https://github.com/n8n-io/n8n/commit/4f94319cd93885ebe830fa1f0e6b757de80f7356))
* **editor:** Add arrow end to connection line ([#10704](https://github.com/n8n-io/n8n/issues/10704)) ([43713dc](https://github.com/n8n-io/n8n/commit/43713dcd89fcb98ea7e24d27127861fc4b0d7872))
* **editor:** Add sticky note readonly state in new canvas ([#10678](https://github.com/n8n-io/n8n/issues/10678)) ([c5bc8e6](https://github.com/n8n-io/n8n/commit/c5bc8e6eb9eadadf44f763e5e5aac4b35d03cc31))
* **editor:** Auto-focus expression input when switching from "fixed" mode ([#10686](https://github.com/n8n-io/n8n/issues/10686)) ([54ab2b1](https://github.com/n8n-io/n8n/commit/54ab2b14e41fe84a455c7e7d5c73d7347844d2fb))
* **editor:** Don't render pinned icon for disabled nodes ([#10712](https://github.com/n8n-io/n8n/issues/10712)) ([879b837](https://github.com/n8n-io/n8n/commit/879b8375812106b3f6909b7de27858175ba5575d))
* **editor:** Fix error rendering and indexing of LLM sub-node outputs ([#10688](https://github.com/n8n-io/n8n/issues/10688)) ([50459ba](https://github.com/n8n-io/n8n/commit/50459bacab517bacb97d2884fda69f8412c9960c))
* **editor:** Fix xss issues in toast usages ([#10733](https://github.com/n8n-io/n8n/issues/10733)) ([6df6f5f](https://github.com/n8n-io/n8n/commit/6df6f5f8df9a8fc0899524a1b69859815eeb341f))
* **editor:** Follow up fixes and improvements to viewer role ([#10684](https://github.com/n8n-io/n8n/issues/10684)) ([63548e6](https://github.com/n8n-io/n8n/commit/63548e6ead5c122732628b5feb1515f492d5e033))
* **editor:** Increase connector snap radius ([#10757](https://github.com/n8n-io/n8n/issues/10757)) ([297b668](https://github.com/n8n-io/n8n/commit/297b668f32f9ecfc82c1205ea4e915408cab482e))
* **editor:** Plus node button should not be visible on readonly mode ([#10692](https://github.com/n8n-io/n8n/issues/10692)) ([62cb189](https://github.com/n8n-io/n8n/commit/62cb189985035c447ad31c275337b3fb24089265))
* **editor:** Prevent action's panel flickering while dragging a node ([#10739](https://github.com/n8n-io/n8n/issues/10739)) ([efa5573](https://github.com/n8n-io/n8n/commit/efa5573278a60d55d5b509aac48cc112c79334d2))
* **editor:** Restrict when the collision avoidance algorithm is used ([#10755](https://github.com/n8n-io/n8n/issues/10755)) ([bf43d67](https://github.com/n8n-io/n8n/commit/bf43d673571b2fc18fe5d660171f0da165909dfc))
* **editor:** Show docs link in credential modal when docs sidebar is hidden ([#10750](https://github.com/n8n-io/n8n/issues/10750)) ([87333cb](https://github.com/n8n-io/n8n/commit/87333cbefebe652256fa1d60ba7a4b946fdfe17d))
* **Email Trigger (IMAP) Node:** Ensure connection close does not block deactivation ([#10689](https://github.com/n8n-io/n8n/issues/10689)) ([156eb72](https://github.com/n8n-io/n8n/commit/156eb72ebefa1d963ff46eff6652e2c947ef031b))
* Fix the issue in Trigger Nodes where poll time was not loaded ([#10695](https://github.com/n8n-io/n8n/issues/10695)) ([1dea8f4](https://github.com/n8n-io/n8n/commit/1dea8f4c7da2a04434c274faf8e0a9a7a693f5a4))
* **Gmail Trigger Node:** Change Gmail Trigger dedupe logic ([#10717](https://github.com/n8n-io/n8n/issues/10717)) ([9f3e03d](https://github.com/n8n-io/n8n/commit/9f3e03d728d8acda5ae4166c5837b00cb1311e96))
* Google Contacts node warm up request, Google Calendar node events>getAll fields option ([#10700](https://github.com/n8n-io/n8n/issues/10700)) ([22c70d5](https://github.com/n8n-io/n8n/commit/22c70d50697023cf448a379d7778695abb718ce9))
* **If Node:** Update copy for type conversion parameter ([#10769](https://github.com/n8n-io/n8n/issues/10769)) ([ee5fbc5](https://github.com/n8n-io/n8n/commit/ee5fbc543ce1d33a56cf118dbd048d6693a15875))
* **n8n Form Trigger Node:** Do not rerun trigger when it has run data ([#10687](https://github.com/n8n-io/n8n/issues/10687)) ([3adbcab](https://github.com/n8n-io/n8n/commit/3adbcab27de34ea5a2c7a88b2ad0d80e3f6d4a0b))
* **OpenAI Chat Model Node:** Prevent filtering of fine-tuned models in model selector ([#10662](https://github.com/n8n-io/n8n/issues/10662)) ([4e89912](https://github.com/n8n-io/n8n/commit/4e899125884bdd97c97446d90e89668688fe7573))
* Prevent AI assistant session reset when workflow is saved ([#10707](https://github.com/n8n-io/n8n/issues/10707)) ([91d9be2](https://github.com/n8n-io/n8n/commit/91d9be20667c20599f64a24fa99386c78476d425))
* Show a more user friendly error message if initial Db connection times out ([#10682](https://github.com/n8n-io/n8n/issues/10682)) ([4efcbc5](https://github.com/n8n-io/n8n/commit/4efcbc593685286837022e5600d81e67f3e0131c))
* **Webflow Node:** Update scopes to include forms ([#10554](https://github.com/n8n-io/n8n/issues/10554)) ([d3861b3](https://github.com/n8n-io/n8n/commit/d3861b31ceef16f566c525c7651453a1b84ed2a4))
* **YouTube Node:** Fix Date filters ([#10725](https://github.com/n8n-io/n8n/issues/10725)) ([21936c8](https://github.com/n8n-io/n8n/commit/21936c88a84b8c03a8d02391cb7112b0e4d9f1f9))
### Features
* **Code Tool Node:** Option to specify input schema ([#10693](https://github.com/n8n-io/n8n/issues/10693)) ([421aa71](https://github.com/n8n-io/n8n/commit/421aa712515d9beeae7c0201b173cb7324473f69))
* **editor:** Add lint for $('Node').item in runOnceForAllItems mode ([#10743](https://github.com/n8n-io/n8n/issues/10743)) ([1b04be1](https://github.com/n8n-io/n8n/commit/1b04be1240ec29151e79162680907710c71c6488))
* **editor:** Logs markdown block improvements ([#10681](https://github.com/n8n-io/n8n/issues/10681)) ([db6e832](https://github.com/n8n-io/n8n/commit/db6e8326c7119d90fa6a51f82099026f50587202))
* Filter parameter: Improve loose type validation for booleans ([#10702](https://github.com/n8n-io/n8n/issues/10702)) ([e9b8d99](https://github.com/n8n-io/n8n/commit/e9b8d99084f0ea2063a1d691928025e534980b4e))
* **Lemlist Node:** Add V2 to support more API operations ([#10615](https://github.com/n8n-io/n8n/issues/10615)) ([20b1cf2](https://github.com/n8n-io/n8n/commit/20b1cf2b7597c78e28f522945b8cbad2ee535cd7))
* **OpenAI Node:** Add Max Tools Iteration parameter and prevent tool calling after execution is aborted ([#10735](https://github.com/n8n-io/n8n/issues/10735)) ([5c47a5f](https://github.com/n8n-io/n8n/commit/5c47a5f691d42dae84a9df8a32a5ea600d83f6dd))
### Performance Improvements
* **editor:** Fix WorkflowDetails excessive re-rendering ([#10767](https://github.com/n8n-io/n8n/issues/10767)) ([00013a2](https://github.com/n8n-io/n8n/commit/00013a2069fff5e5d9398c5921c90d34dc384299))
# [1.58.0](https://github.com/n8n-io/n8n/compare/n8n@1.57.0...n8n@1.58.0) (2024-09-05)
### Bug Fixes
* **AI Agent Node:** Fix tools agent when using memory and Anthropic models ([#10513](https://github.com/n8n-io/n8n/issues/10513)) ([746e7b8](https://github.com/n8n-io/n8n/commit/746e7b89f7e9b99126fb69110773548dfe91b74f))
* **API:** Update express-openapi-validator to resolve AIKIDO-2024-10229 ([#10612](https://github.com/n8n-io/n8n/issues/10612)) ([1dcb814](https://github.com/n8n-io/n8n/commit/1dcb814ced7cfbc80eddbb4bc03108341a9f27f5))
* **core:** Declutter webhook insertion errors ([#10650](https://github.com/n8n-io/n8n/issues/10650)) ([36177b0](https://github.com/n8n-io/n8n/commit/36177b0943cf72bae3b0075453498dd1e41684d0))
* **core:** Flush responses for ai streaming endpoints ([#10633](https://github.com/n8n-io/n8n/issues/10633)) ([6bb6a5c](https://github.com/n8n-io/n8n/commit/6bb6a5c6cd1da3503a1a2b35bcf4c685cd3f964f))
* **core:** Tighten check for company size survey answer ([#10646](https://github.com/n8n-io/n8n/issues/10646)) ([e5aba60](https://github.com/n8n-io/n8n/commit/e5aba60afff93364d91f17c00ea18d38d9dbc970))
* **editor:** Add confirmation toast when changing user role ([#10592](https://github.com/n8n-io/n8n/issues/10592)) ([95da4d4](https://github.com/n8n-io/n8n/commit/95da4d4797e800c04b2b17c23c941c785dd62393))
* **editor:** Add pinned data only to manual executions in execution view ([#10605](https://github.com/n8n-io/n8n/issues/10605)) ([a12e9ed](https://github.com/n8n-io/n8n/commit/a12e9edac042957939c63f0a5c35572930632352))
* **editor:** Add tooltips to workflow history button ([#10570](https://github.com/n8n-io/n8n/issues/10570)) ([4a125f5](https://github.com/n8n-io/n8n/commit/4a125f511c5537977652900b7712a2ad908140e7))
* **editor:** Allow disabling SSO when config request fails ([#10635](https://github.com/n8n-io/n8n/issues/10635)) ([ce39933](https://github.com/n8n-io/n8n/commit/ce39933766fa18107f4082de0cba0b6702cbbbfa))
* **editor:** Fix notification rendering HTML as text ([#10642](https://github.com/n8n-io/n8n/issues/10642)) ([5eba534](https://github.com/n8n-io/n8n/commit/5eba5343191665cd4639632ba303464176c279c4))
* **editor:** Fix opening executions tab from a new, unsaved workflow ([#10652](https://github.com/n8n-io/n8n/issues/10652)) ([cd0891e](https://github.com/n8n-io/n8n/commit/cd0891e4f1cfdc90b2090958a39564ba99534627))
* **Gmail Trigger Node:** Don't return date instances, but date strings instead ([#10582](https://github.com/n8n-io/n8n/issues/10582)) ([9e1dac0](https://github.com/n8n-io/n8n/commit/9e1dac04655a20c5c7b99552742312fd9237604b))
* **HTTP Request Node:** Sanitize authorization headers ([#10607](https://github.com/n8n-io/n8n/issues/10607)) ([405c55a](https://github.com/n8n-io/n8n/commit/405c55a1f7cf34e7b6e46a86031ef9a41956ca78))
* **Wait Node:** Append n8n attribution option ([#10585](https://github.com/n8n-io/n8n/issues/10585)) ([81f4322](https://github.com/n8n-io/n8n/commit/81f4322d456773281aec4b47447465bdffd311fe))
### Features
* **core:** Execution curation ([#10342](https://github.com/n8n-io/n8n/issues/10342)) ([022ddcb](https://github.com/n8n-io/n8n/commit/022ddcbef9f1ac1b89bcfd5f7759d67325b07392))
* **core:** Implement wrapping of regular nodes as AI Tools ([#10641](https://github.com/n8n-io/n8n/issues/10641)) ([da44fe4](https://github.com/n8n-io/n8n/commit/da44fe4b8967055b7b1f849750e1fafa0ba67218))
* **core:** Introduce DB health check ([#10661](https://github.com/n8n-io/n8n/issues/10661)) ([a8e80d0](https://github.com/n8n-io/n8n/commit/a8e80d0c4b7531fe32be1d4057656885359f42fc))
* **core:** Make Postgres connection timeout configurable ([#10670](https://github.com/n8n-io/n8n/issues/10670)) ([8154031](https://github.com/n8n-io/n8n/commit/81540318b4c55f3a09c9776e23d2211abdbd36f7))
* **core:** Switch to MJML for email templates ([#10518](https://github.com/n8n-io/n8n/issues/10518)) ([dbc10fe](https://github.com/n8n-io/n8n/commit/dbc10fe9f522f31eb06add6f3f6863ce24510547))
* **editor:** Add A/B testing feature flag for credential docs modal ([#10664](https://github.com/n8n-io/n8n/issues/10664)) ([899b0a1](https://github.com/n8n-io/n8n/commit/899b0a19efc49c1c087f78bbb1a59d726a510965))
* **editor:** Add AI Assistant support chat ([#10656](https://github.com/n8n-io/n8n/issues/10656)) ([3a80780](https://github.com/n8n-io/n8n/commit/3a8078068e5c0b01dfd34ff838fe1b30d604abc6))
* **editor:** Implement new app layout ([#10548](https://github.com/n8n-io/n8n/issues/10548)) ([95a9cd2](https://github.com/n8n-io/n8n/commit/95a9cd2c739cf4f817eb8df6509a9112ac24a3b1))
* **editor:** Make highlighted data pane floating ([#10638](https://github.com/n8n-io/n8n/issues/10638)) ([8b5c333](https://github.com/n8n-io/n8n/commit/8b5c333d3dca03ba51a5873b75451fbfafc5ae15))
* More hints to nodes ([#10565](https://github.com/n8n-io/n8n/issues/10565)) ([66ddb4a](https://github.com/n8n-io/n8n/commit/66ddb4a6f367602c9aaad1bfb0cc6fac3facd15e))
* **Postgres PGVector Store Node:** Add PGVector vector store node ([#10517](https://github.com/n8n-io/n8n/issues/10517)) ([650389d](https://github.com/n8n-io/n8n/commit/650389d90763a45c037e74a1a1193c3cbe103a16))
* Reintroduce collaboration feature ([#10602](https://github.com/n8n-io/n8n/issues/10602)) ([2ea2bfe](https://github.com/n8n-io/n8n/commit/2ea2bfe762c02047e522f28dd97f197735b3fb46))
* **Text Classifier Node:** Add output fixing parser ([#10667](https://github.com/n8n-io/n8n/issues/10667)) ([aa37c32](https://github.com/n8n-io/n8n/commit/aa37c32f266ffff93cd903888b1c15caa0468830))
# [1.57.0](https://github.com/n8n-io/n8n/compare/n8n@1.56.0...n8n@1.57.0) (2024-08-28)

48
biome.jsonc Normal file
View file

@ -0,0 +1,48 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": {
"clientKind": "git",
"enabled": true,
"useIgnoreFile": true
},
"files": {
"ignore": [
"**/.turbo",
"**/coverage",
"**/dist",
"**/package.json",
"**/pnpm-lock.yaml",
"**/CHANGELOG.md"
]
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "tab",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 100,
"attributePosition": "auto",
"ignore": [
// Handled by prettier
"**/*.vue"
]
},
"organizeImports": { "enabled": false },
"linter": {
"enabled": false
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "all",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSpacing": true,
"bracketSameLine": false,
"quoteStyle": "single",
"attributePosition": "auto"
}
}
}

7
cypress/biome.jsonc Normal file
View file

@ -0,0 +1,7 @@
{
"$schema": "../node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["../biome.jsonc"],
"formatter": {
"ignore": ["fixtures/**"]
}
}

View file

@ -9,223 +9,252 @@ const executionsTab = new WorkflowExecutionsTab();
const executionsRefreshInterval = 4000;
// Test suite for executions tab
describe('Current Workflow Executions', () => {
beforeEach(() => {
workflowPage.actions.visit();
cy.createFixtureWorkflow('Test_workflow_4_executions_view.json', 'My test workflow');
});
it('should render executions tab correctly', () => {
createMockExecutions();
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
executionsTab.actions.switchToExecutionsTab();
cy.wait(['@getExecutions']);
executionsTab.getters.executionsList().scrollTo(0, 500).wait(0);
executionsTab.getters.executionListItems().should('have.length', 11);
executionsTab.getters.successfulExecutionListItems().should('have.length', 9);
executionsTab.getters.failedExecutionListItems().should('have.length', 2);
executionsTab.getters
.executionListItems()
.first()
.invoke('attr', 'class')
.should('match', /_active_/);
});
it('should not redirect back to execution tab when request is not done before leaving the page', () => {
cy.intercept('GET', '/rest/executions?filter=*');
cy.intercept('GET', '/rest/executions/active?filter=*');
executionsTab.actions.switchToExecutionsTab();
executionsTab.actions.switchToEditorTab();
cy.wait(executionsRefreshInterval);
cy.url().should('not.include', '/executions');
executionsTab.actions.switchToExecutionsTab();
executionsTab.actions.switchToEditorTab();
executionsTab.actions.switchToExecutionsTab();
executionsTab.actions.switchToEditorTab();
executionsTab.actions.switchToExecutionsTab();
executionsTab.actions.switchToEditorTab();
cy.wait(executionsRefreshInterval);
cy.url().should('not.include', '/executions');
executionsTab.actions.switchToExecutionsTab();
cy.wait(1000);
executionsTab.actions.switchToEditorTab();
cy.wait(executionsRefreshInterval);
cy.url().should('not.include', '/executions');
});
it('should not redirect back to execution tab when slow request is not done before leaving the page', () => {
const throttleResponse: RouteHandler = async (req) => {
return await new Promise((resolve) => {
setTimeout(() => resolve(req.continue()), 2000);
});
};
cy.intercept('GET', '/rest/executions?filter=*', throttleResponse);
cy.intercept('GET', '/rest/executions/active?filter=*', throttleResponse);
executionsTab.actions.switchToExecutionsTab();
executionsTab.actions.switchToEditorTab();
cy.wait(executionsRefreshInterval);
cy.url().should('not.include', '/executions');
});
it('should error toast when server error message returned without stack trace', () => {
executionsTab.actions.createManualExecutions(1);
const message = 'Workflow did not finish, possible out-of-memory issue';
cy.intercept('GET', '/rest/executions/*', {
statusCode: 200,
body: executionOutOfMemoryServerResponse,
}).as('getExecution');
executionsTab.actions.switchToExecutionsTab();
cy.wait(['@getExecution']);
executionsTab.getters
.workflowExecutionPreviewIframe()
.should('be.visible')
.its('0.contentDocument.body') // Access the body of the iframe document
.should('not.be.empty') // Ensure the body is not empty
.then(cy.wrap)
.find('.el-notification:has(.el-notification--error)')
.should('be.visible')
.filter(`:contains("${message}")`)
.should('be.visible');
});
it('should show workflow data in executions tab after hard reload and modify name and tags', () => {
executionsTab.actions.switchToExecutionsTab();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 2);
workflowPage.getters.workflowTags().click();
getVisibleSelect().find('li:contains("Manage tags")').click();
cy.get('button:contains("Add new")').click();
cy.getByTestId('tags-table').find('input').type('nutag').type('{enter}');
cy.get('button:contains("Done")').click();
cy.reload();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.workflowTags().click();
workflowPage.getters.tagsInDropdown().first().should('have.text', 'nutag').click();
workflowPage.getters.tagPills().should('have.length', 3);
let newWorkflowName = 'Renamed workflow';
workflowPage.actions.renameWorkflow(newWorkflowName);
workflowPage.getters.isWorkflowSaved();
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
executionsTab.actions.switchToEditorTab();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 3);
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
executionsTab.actions.switchToExecutionsTab();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 3);
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
executionsTab.actions.switchToEditorTab();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 3);
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
newWorkflowName = 'New workflow';
workflowPage.actions.renameWorkflow(newWorkflowName);
workflowPage.getters.isWorkflowSaved();
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
workflowPage.getters.workflowTags().click();
workflowPage.getters.tagsDropdown().find('.el-tag__close').first().click();
cy.get('body').click(0, 0);
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 2);
executionsTab.actions.switchToExecutionsTab();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 2);
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
executionsTab.actions.switchToEditorTab();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 2);
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
});
it('should load items and auto scroll after filter change', () => {
createMockExecutions();
createMockExecutions();
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
executionsTab.actions.switchToExecutionsTab();
cy.wait(['@getExecutions']);
executionsTab.getters.executionsList().scrollTo(0, 500).wait(0);
executionsTab.getters.executionListItems().eq(10).click();
cy.getByTestId('executions-filter-button').click();
cy.getByTestId('executions-filter-status-select').should('be.visible').click();
getVisibleSelect().find('li:contains("Error")').click();
executionsTab.getters.executionListItems().should('have.length', 5);
executionsTab.getters.successfulExecutionListItems().should('have.length', 1);
executionsTab.getters.failedExecutionListItems().should('have.length', 4);
cy.getByTestId('executions-filter-button').click();
cy.getByTestId('executions-filter-status-select').should('be.visible').click();
getVisibleSelect().find('li:contains("Success")').click();
// check if the list is scrolled
executionsTab.getters.executionListItems().eq(10).should('be.visible');
executionsTab.getters.executionsList().then(($el) => {
const { scrollTop, scrollHeight, clientHeight } = $el[0];
expect(scrollTop).to.be.greaterThan(0);
expect(scrollTop + clientHeight).to.be.lessThan(scrollHeight);
// scroll to the bottom
$el[0].scrollTo(0, scrollHeight);
executionsTab.getters.executionListItems().should('have.length', 18);
executionsTab.getters.successfulExecutionListItems().should('have.length', 18);
executionsTab.getters.failedExecutionListItems().should('have.length', 0);
describe('Workflow Executions', () => {
describe('when workflow is saved', () => {
beforeEach(() => {
workflowPage.actions.visit();
cy.createFixtureWorkflow('Test_workflow_4_executions_view.json', 'My test workflow');
});
cy.getByTestId('executions-filter-button').click();
cy.getByTestId('executions-filter-reset-button').should('be.visible').click();
executionsTab.getters.executionListItems().eq(11).should('be.visible');
it('should render executions tab correctly', () => {
createMockExecutions();
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
executionsTab.actions.switchToExecutionsTab();
cy.wait(['@getExecutions']);
executionsTab.getters.executionsList().scrollTo(0, 500).wait(0);
executionsTab.getters.executionListItems().should('have.length', 11);
executionsTab.getters.successfulExecutionListItems().should('have.length', 9);
executionsTab.getters.failedExecutionListItems().should('have.length', 2);
executionsTab.getters
.executionListItems()
.first()
.invoke('attr', 'class')
.should('match', /_active_/);
});
it('should not redirect back to execution tab when request is not done before leaving the page', () => {
cy.intercept('GET', '/rest/executions?filter=*');
cy.intercept('GET', '/rest/executions/active?filter=*');
executionsTab.actions.switchToExecutionsTab();
executionsTab.actions.switchToEditorTab();
cy.wait(executionsRefreshInterval);
cy.url().should('not.include', '/executions');
executionsTab.actions.switchToExecutionsTab();
executionsTab.actions.switchToEditorTab();
executionsTab.actions.switchToExecutionsTab();
executionsTab.actions.switchToEditorTab();
executionsTab.actions.switchToExecutionsTab();
executionsTab.actions.switchToEditorTab();
cy.wait(executionsRefreshInterval);
cy.url().should('not.include', '/executions');
executionsTab.actions.switchToExecutionsTab();
cy.wait(1000);
executionsTab.actions.switchToEditorTab();
cy.wait(executionsRefreshInterval);
cy.url().should('not.include', '/executions');
});
it('should not redirect back to execution tab when slow request is not done before leaving the page', () => {
const throttleResponse: RouteHandler = async (req) => {
return await new Promise((resolve) => {
setTimeout(() => resolve(req.continue()), 2000);
});
};
cy.intercept('GET', '/rest/executions?filter=*', throttleResponse);
cy.intercept('GET', '/rest/executions/active?filter=*', throttleResponse);
executionsTab.actions.switchToExecutionsTab();
executionsTab.actions.switchToEditorTab();
cy.wait(executionsRefreshInterval);
cy.url().should('not.include', '/executions');
});
it('should error toast when server error message returned without stack trace', () => {
executionsTab.actions.createManualExecutions(1);
const message = 'Workflow did not finish, possible out-of-memory issue';
cy.intercept('GET', '/rest/executions/*', {
statusCode: 200,
body: executionOutOfMemoryServerResponse,
}).as('getExecution');
executionsTab.actions.switchToExecutionsTab();
cy.wait(['@getExecution']);
executionsTab.getters
.workflowExecutionPreviewIframe()
.should('be.visible')
.its('0.contentDocument.body') // Access the body of the iframe document
.should('not.be.empty') // Ensure the body is not empty
.then(cy.wrap)
.find('.el-notification:has(.el-notification--error)')
.should('be.visible')
.filter(`:contains("${message}")`)
.should('be.visible');
});
it('should show workflow data in executions tab after hard reload and modify name and tags', () => {
executionsTab.actions.switchToExecutionsTab();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 2);
workflowPage.getters.workflowTags().click();
getVisibleSelect().find('li:contains("Manage tags")').click();
cy.get('button:contains("Add new")').click();
cy.getByTestId('tags-table').find('input').type('nutag').type('{enter}');
cy.get('button:contains("Done")').click();
cy.reload();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.workflowTags().click();
workflowPage.getters.tagsInDropdown().first().should('have.text', 'nutag').click();
workflowPage.getters.tagPills().should('have.length', 3);
let newWorkflowName = 'Renamed workflow';
workflowPage.actions.renameWorkflow(newWorkflowName);
workflowPage.getters.isWorkflowSaved();
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
executionsTab.actions.switchToEditorTab();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 3);
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
executionsTab.actions.switchToExecutionsTab();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 3);
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
executionsTab.actions.switchToEditorTab();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 3);
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
newWorkflowName = 'New workflow';
workflowPage.actions.renameWorkflow(newWorkflowName);
workflowPage.getters.isWorkflowSaved();
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
workflowPage.getters.workflowTags().click();
workflowPage.getters.tagsDropdown().find('.el-tag__close').first().click();
cy.get('body').click(0, 0);
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 2);
executionsTab.actions.switchToExecutionsTab();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 2);
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
executionsTab.actions.switchToEditorTab();
checkMainHeaderELements();
workflowPage.getters.saveButton().find('button').should('not.exist');
workflowPage.getters.tagPills().should('have.length', 2);
workflowPage.getters
.workflowNameInputContainer()
.invoke('attr', 'title')
.should('eq', newWorkflowName);
});
it('should load items and auto scroll after filter change', () => {
createMockExecutions();
createMockExecutions();
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
executionsTab.actions.switchToExecutionsTab();
cy.wait(['@getExecutions']);
executionsTab.getters.executionsList().scrollTo(0, 500).wait(0);
executionsTab.getters.executionListItems().eq(10).click();
cy.getByTestId('executions-filter-button').click();
cy.getByTestId('executions-filter-status-select').should('be.visible').click();
getVisibleSelect().find('li:contains("Error")').click();
executionsTab.getters.executionListItems().should('have.length', 5);
executionsTab.getters.successfulExecutionListItems().should('have.length', 1);
executionsTab.getters.failedExecutionListItems().should('have.length', 4);
cy.getByTestId('executions-filter-button').click();
cy.getByTestId('executions-filter-status-select').should('be.visible').click();
getVisibleSelect().find('li:contains("Success")').click();
// check if the list is scrolled
executionsTab.getters.executionListItems().eq(10).should('be.visible');
executionsTab.getters.executionsList().then(($el) => {
const { scrollTop, scrollHeight, clientHeight } = $el[0];
expect(scrollTop).to.be.greaterThan(0);
expect(scrollTop + clientHeight).to.be.lessThan(scrollHeight);
// scroll to the bottom
$el[0].scrollTo(0, scrollHeight);
executionsTab.getters.executionListItems().should('have.length', 18);
executionsTab.getters.successfulExecutionListItems().should('have.length', 18);
executionsTab.getters.failedExecutionListItems().should('have.length', 0);
});
cy.getByTestId('executions-filter-button').click();
cy.getByTestId('executions-filter-reset-button').should('be.visible').click();
executionsTab.getters.executionListItems().eq(11).should('be.visible');
});
});
describe('when new workflow is not saved', () => {
beforeEach(() => {
workflowPage.actions.visit();
});
it('should open executions tab', () => {
executionsTab.actions.switchToExecutionsTab();
executionsTab.getters.executionsSidebar().should('be.visible');
executionsTab.getters.executionsEmptyList().should('be.visible');
cy.getByTestId('workflow-execution-no-trigger-content').should('be.visible');
cy.get('button:contains("Add first step")').should('be.visible').click();
cy.getByTestId('node-creator-item-name')
.should('be.visible')
.filter(':contains("Trigger")')
.click();
executionsTab.actions.switchToExecutionsTab();
executionsTab.getters.executionsSidebar().should('be.visible');
executionsTab.getters.executionsEmptyList().should('be.visible');
cy.getByTestId('workflow-execution-no-content').should('be.visible');
workflowPage.getters.saveButton().find('button').should('be.enabled').click();
workflowPage.getters.isWorkflowSaved();
workflowPage.getters.nodeViewRoot().should('be.visible');
});
});
});
@ -233,9 +262,11 @@ const createMockExecutions = () => {
executionsTab.actions.createManualExecutions(5);
// Make some failed executions by enabling Code node with syntax error
executionsTab.actions.toggleNodeEnabled('Error');
workflowPage.getters.disabledNodes().should('have.length', 0);
executionsTab.actions.createManualExecutions(2);
// Then add some more successful ones
executionsTab.actions.toggleNodeEnabled('Error');
workflowPage.getters.disabledNodes().should('have.length', 1);
executionsTab.actions.createManualExecutions(4);
};

View file

@ -227,7 +227,7 @@ describe('NDV', () => {
workflowPage.actions.zoomToFit();
/* prettier-ignore */
// biome-ignore format:
const PINNED_DATA = [
{
"id": "abc",
@ -263,7 +263,6 @@ describe('NDV', () => {
]
}
];
/* prettier-ignore */
workflowPage.actions.openNode('Get thread details1');
ndv.actions.pastePinnedData(PINNED_DATA);
ndv.actions.close();

View file

@ -1,9 +1,13 @@
import { NDV, WorkflowPage } from '../pages';
import { clickCreateNewCredential, openCredentialSelect } from '../composables/ndv';
import { GMAIL_NODE_NAME, SCHEDULE_TRIGGER_NODE_NAME } from '../constants';
import { CredentialsModal, CredentialsPage, NDV, WorkflowPage } from '../pages';
import { AIAssistant } from '../pages/features/ai-assistant';
const wf = new WorkflowPage();
const ndv = new NDV();
const aiAssistant = new AIAssistant();
const credentialsPage = new CredentialsPage();
const credentialsModal = new CredentialsModal();
describe('AI Assistant::disabled', () => {
beforeEach(() => {
@ -31,7 +35,8 @@ describe('AI Assistant::enabled', () => {
aiAssistant.getters.askAssistantFloatingButton().click();
aiAssistant.getters.askAssistantChat().should('be.visible');
aiAssistant.getters.placeholderMessage().should('be.visible');
aiAssistant.getters.chatInputWrapper().should('not.exist');
aiAssistant.getters.chatInput().should('be.visible');
aiAssistant.getters.sendMessageButton().should('be.disabled');
aiAssistant.getters.closeChatButton().should('be.visible');
aiAssistant.getters.closeChatButton().click();
aiAssistant.getters.askAssistantChat().should('not.be.visible');
@ -130,17 +135,24 @@ describe('AI Assistant::enabled', () => {
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
cy.wait('@chatRequest');
aiAssistant.getters.quickReplies().should('have.length', 2);
aiAssistant.getters.quickReplies().eq(0).click();
aiAssistant.getters.quickReplyButtons().should('have.length', 2);
aiAssistant.getters.quickReplyButtons().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',
it('should show quick replies when node is executed after new suggestion', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', (req) => {
req.reply((res) => {
if (['init-error-helper', 'message'].includes(req.body.payload.type)) {
res.send({ statusCode: 200, fixture: 'aiAssistant/simple_message_response.json' });
} else if (req.body.payload.type === 'event') {
res.send({ statusCode: 200, fixture: 'aiAssistant/node_execution_error_response.json' });
} else {
res.send({ statusCode: 500 });
}
});
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
wf.actions.openNode('Edit Fields');
@ -148,10 +160,17 @@ describe('AI Assistant::enabled', () => {
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);
// Respond 'Yes' to the quick reply (request new suggestion)
aiAssistant.getters.quickReplies().contains('Yes').click();
cy.wait('@chatRequest');
// No quick replies at this point
aiAssistant.getters.quickReplies().should('not.exist');
ndv.getters.nodeExecuteButton().click();
// But after executing the node again, quick replies should be shown
aiAssistant.getters.chatMessagesAssistant().should('have.length', 4);
aiAssistant.getters.quickReplies().should('have.length', 2);
});
it('should warn before starting a new session', () => {
@ -244,4 +263,169 @@ describe('AI Assistant::enabled', () => {
aiAssistant.getters.chatMessagesSystem().should('have.length', 1);
aiAssistant.getters.chatMessagesSystem().first().should('contain.text', 'session has ended');
});
it('should reset session after it ended and sidebar is closed', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', (req) => {
req.reply((res) => {
if (['init-support-chat'].includes(req.body.payload.type)) {
res.send({ statusCode: 200, fixture: 'aiAssistant/simple_message_response.json' });
} else {
res.send({ statusCode: 200, fixture: 'aiAssistant/end_session_response.json' });
}
});
}).as('chatRequest');
aiAssistant.actions.openChat();
aiAssistant.actions.sendMessage('Hello');
cy.wait('@chatRequest');
aiAssistant.actions.closeChat();
aiAssistant.actions.openChat();
// After closing and reopening the chat, all messages should be still there
aiAssistant.getters.chatMessagesAll().should('have.length', 2);
// End the session
aiAssistant.actions.sendMessage('Thanks, bye');
cy.wait('@chatRequest');
aiAssistant.getters.chatMessagesSystem().should('have.length', 1);
aiAssistant.getters.chatMessagesSystem().first().should('contain.text', 'session has ended');
aiAssistant.actions.closeChat();
aiAssistant.actions.openChat();
// Now, session should be reset
aiAssistant.getters.placeholderMessage().should('be.visible');
});
it('Should not reset assistant session when workflow is saved', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
}).as('chatRequest');
wf.actions.addInitialNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
aiAssistant.actions.openChat();
aiAssistant.actions.sendMessage('Hello');
wf.actions.openNode(SCHEDULE_TRIGGER_NODE_NAME);
ndv.getters.nodeExecuteButton().click();
wf.getters.isWorkflowSaved();
aiAssistant.getters.placeholderMessage().should('not.exist');
});
});
describe('AI Assistant Credential Help', () => {
beforeEach(() => {
aiAssistant.actions.enableAssistant();
wf.actions.visit();
});
after(() => {
aiAssistant.actions.disableAssistant();
});
it('should start credential help from node credential', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
}).as('chatRequest');
wf.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
wf.actions.addNodeToCanvas(GMAIL_NODE_NAME);
wf.actions.openNode('Gmail');
openCredentialSelect();
clickCreateNewCredential();
aiAssistant.getters.credentialEditAssistantButton().should('be.visible');
aiAssistant.getters.credentialEditAssistantButton().click();
cy.wait('@chatRequest');
aiAssistant.getters.chatMessagesUser().should('have.length', 1);
aiAssistant.getters
.chatMessagesUser()
.eq(0)
.should('contain.text', 'How do I set up the credentials for Gmail OAuth2 API?');
aiAssistant.getters
.chatMessagesAssistant()
.eq(0)
.should('contain.text', 'Hey, this is an assistant message');
aiAssistant.getters.credentialEditAssistantButton().should('be.disabled');
});
it('should start credential help from credential list', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
statusCode: 200,
fixture: 'aiAssistant/simple_message_response.json',
}).as('chatRequest');
cy.visit(credentialsPage.url);
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.getters.newCredentialModal().should('be.visible');
credentialsModal.getters.newCredentialTypeSelect().should('be.visible');
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
credentialsModal.getters.newCredentialTypeButton().click();
aiAssistant.getters.credentialEditAssistantButton().should('be.visible');
aiAssistant.getters.credentialEditAssistantButton().click();
cy.wait('@chatRequest');
aiAssistant.getters.chatMessagesUser().should('have.length', 1);
aiAssistant.getters
.chatMessagesUser()
.eq(0)
.should('contain.text', 'How do I set up the credentials for Notion API?');
aiAssistant.getters
.chatMessagesAssistant()
.eq(0)
.should('contain.text', 'Hey, this is an assistant message');
aiAssistant.getters.credentialEditAssistantButton().should('be.disabled');
});
});
describe('General help', () => {
beforeEach(() => {
aiAssistant.actions.enableAssistant();
wf.actions.visit();
});
it('assistant returns code snippet', () => {
cy.intercept('POST', '/rest/ai-assistant/chat', {
statusCode: 200,
fixture: 'aiAssistant/code_snippet_response.json',
}).as('chatRequest');
aiAssistant.getters.askAssistantFloatingButton().should('be.visible');
aiAssistant.getters.askAssistantFloatingButton().click();
aiAssistant.getters.askAssistantChat().should('be.visible');
aiAssistant.getters.placeholderMessage().should('be.visible');
aiAssistant.getters.chatInput().should('be.visible');
aiAssistant.getters.chatInput().type('Show me an expression');
aiAssistant.getters.sendMessageButton().click();
aiAssistant.getters.chatMessagesAll().should('have.length', 3);
aiAssistant.getters.chatMessagesUser().eq(0).should('contain.text', 'Show me an expression');
aiAssistant.getters
.chatMessagesAssistant()
.eq(0)
.should('contain.text', 'To use expressions in n8n, follow these steps:');
aiAssistant.getters
.chatMessagesAssistant()
.eq(0)
.should(
'include.html',
`<pre><code class="language-json">[
{
"headers": {
"host": "n8n.instance.address",
...
},
"params": {},
"query": {},
"body": {
"name": "Jim",
"age": 30,
"city": "New York"
}
}
]
</code></pre>`,
);
aiAssistant.getters.codeSnippet().should('have.text', '{{$json.body.city}}');
});
});

View file

@ -132,6 +132,10 @@ describe('NDV', () => {
'contains.text',
"An expression here won't work because it uses .item and n8n can't figure out the matching item.",
);
ndv.getters.nodeRunErrorIndicator().should('be.visible');
// The error details should be hidden behind a tooltip
ndv.getters.nodeRunErrorIndicator().should('not.contain', 'Start Time');
ndv.getters.nodeRunErrorIndicator().should('not.contain', 'Execution Time');
});
it('should save workflow using keyboard shortcut from NDV', () => {
@ -582,7 +586,13 @@ describe('NDV', () => {
ndv.getters.outputTableRow(1).find('mark').should('have.text', '<lib');
ndv.getters.outputDisplayMode().find('label').eq(1).should('include.text', 'JSON');
ndv.getters.outputDisplayMode().find('label').eq(1).click();
ndv.getters
.outputDisplayMode()
.find('label')
.eq(1)
.scrollIntoView()
.should('be.visible')
.click();
ndv.getters.outputDataContainer().find('.json-data').should('exist');
ndv.getters

View file

@ -38,6 +38,55 @@ describe('Code node', () => {
successToast().contains('Node executed successfully');
});
it('should show lint errors in `runOnceForAllItems` mode', () => {
const getParameter = () => ndv.getters.parameterInput('jsCode').should('be.visible');
const getEditor = () => getParameter().find('.cm-content').should('exist');
getEditor()
.type('{selectall}')
.paste(`$input.itemMatching()
$input.item
$('When clicking Test workflow').item
$input.first(1)
for (const item of $input.all()) {
item.foo
}
return
`);
getParameter().get('.cm-lint-marker-error').should('have.length', 6);
getParameter().contains('itemMatching').realHover();
cy.get('.cm-tooltip-lint').should(
'have.text',
'`.itemMatching()` expects an item index to be passed in as its argument.',
);
});
it('should show lint errors in `runOnceForEachItem` mode', () => {
const getParameter = () => ndv.getters.parameterInput('jsCode').should('be.visible');
const getEditor = () => getParameter().find('.cm-content').should('exist');
ndv.getters.parameterInput('mode').click();
ndv.actions.selectOptionInParameterDropdown('mode', 'Run Once for Each Item');
getEditor()
.type('{selectall}')
.paste(`$input.itemMatching()
$input.all()
$input.first()
$input.item()
return []
`);
getParameter().get('.cm-lint-marker-error').should('have.length', 5);
getParameter().contains('all').realHover();
cy.get('.cm-tooltip-lint').should(
'have.text',
"Method `$input.all()` is only available in the 'Run Once for All Items' mode.",
);
});
});
describe('Ask AI', () => {

View file

@ -0,0 +1,28 @@
{
"sessionId": "f1d19ed5-0d55-4bad-b49a-f0c56bd6f76f-705b5dbf-12d4-4805-87a3-1e5b3c716d29-W1JgVNrpfitpSNF9rAjB4",
"messages": [
{
"role": "assistant",
"type": "message",
"text": "To use expressions in n8n, follow these steps:\n\n1. Hover over the parameter where you want to use an expression.\n2. Select **Expressions** in the **Fixed/Expression** toggle.\n3. Write your expression in the parameter, or select **Open expression editor** to open the expressions editor. You can browse the available data in the **Variable selector**. All expressions have the format `{{ your expression here }}`.\n\n### Example: Get data from webhook body\n\nIf your webhook data looks like this:\n\n```json\n[\n {\n \"headers\": {\n \"host\": \"n8n.instance.address\",\n ...\n },\n \"params\": {},\n \"query\": {},\n \"body\": {\n \"name\": \"Jim\",\n \"age\": 30,\n \"city\": \"New York\"\n }\n }\n]\n```\n\nYou can use the following expression to get the value of `city`:\n\n```js\n{{$json.body.city}}\n```\n\nThis expression accesses the incoming JSON-formatted data using n8n's custom `$json` variable and finds the value of `city` (in this example, \"New York\").",
"codeSnippet": "{{$json.body.city}}"
},
{
"role": "assistant",
"type": "message",
"text": "Did this answer solve your question?",
"quickReplies": [
{
"text": "Yes, thanks",
"type": "all-good",
"isFeedback": true
},
{
"text": "No, I am still stuck",
"type": "still-stuck",
"isFeedback": true
}
]
}
]
}

View file

@ -1,9 +1,9 @@
{
"sessionId": "f9130bd7-c078-4862-a38a-369b27b0ff20-e96eb9f7-d581-4684-b6a9-fd3dfe9fe1fb-XCldJLlusGrEVku5I9cYT",
"sessionId": "1",
"messages": [
{
"role": "assistant",
"type": "agent-suggestion",
"type": "message",
"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!"
},

View file

@ -0,0 +1,20 @@
{
"sessionId": "1",
"messages": [
{
"role": "assistant",
"type": "message",
"text": "It seems like my suggestion did not work. Do you want me to come up with a different suggestion? You can also provide more context via the chat.",
"quickReplies": [
{
"text": "Yes",
"type": "new-suggestion"
},
{
"text": "No, I don't think you can help",
"type": "event:end-session"
}
]
}
]
}

View file

@ -7,13 +7,15 @@
"test:e2e:ui": "scripts/run-e2e.js ui",
"test:e2e:dev": "scripts/run-e2e.js dev",
"test:e2e:all": "scripts/run-e2e.js all",
"format": "prettier --write . --ignore-path ../.prettierignore",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"develop": "cd ..; pnpm dev",
"start": "cd ..; pnpm start"
},
"devDependencies": {
"@n8n/api-types": "workspace:*",
"@types/lodash": "catalog:",
"eslint-plugin-cypress": "^3.3.0",
"n8n-workflow": "workspace:*"

View file

@ -26,7 +26,8 @@ export class AIAssistant extends BasePage {
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'),
quickReplies: () => cy.getByTestId('quick-replies'),
quickReplyButtons: () => this.getters.quickReplies().find('button'),
newAssistantSessionModal: () => cy.getByTestId('new-assistant-session-modal'),
codeDiffs: () => cy.getByTestId('code-diff-suggestion'),
applyCodeDiffButtons: () => cy.getByTestId('replace-code-button'),
@ -34,16 +35,30 @@ export class AIAssistant extends BasePage {
codeReplacedMessage: () => cy.getByTestId('code-replaced-message'),
nodeErrorViewAssistantButton: () =>
cy.getByTestId('node-error-view-ask-assistant-button').find('button').first(),
credentialEditAssistantButton: () =>
cy.getByTestId('credentail-edit-ask-assistant-button').find('button').first(),
codeSnippet: () => cy.getByTestId('assistant-code-snippet'),
};
actions = {
enableAssistant(): void {
enableAssistant: () => {
overrideFeatureFlag(AI_ASSISTANT_FEATURE.experimentName, AI_ASSISTANT_FEATURE.enabledFor);
cy.enableFeature(AI_ASSISTANT_FEATURE.name);
},
disableAssistant(): void {
disableAssistant: () => {
overrideFeatureFlag(AI_ASSISTANT_FEATURE.experimentName, AI_ASSISTANT_FEATURE.disabledFor);
cy.disableFeature(AI_ASSISTANT_FEATURE.name);
},
sendMessage: (message: string) => {
this.getters.chatInput().type(message).type('{enter}');
},
closeChat: () => {
this.getters.closeChatButton().click();
this.getters.askAssistantChat().should('not.be.visible');
},
openChat: () => {
this.getters.askAssistantFloatingButton().click();
this.getters.askAssistantChat().should('be.visible');
},
};
}

View file

@ -7,6 +7,7 @@ export class WorkflowExecutionsTab extends BasePage {
getters = {
executionsTabButton: () => cy.getByTestId('radio-button-executions'),
executionsSidebar: () => cy.getByTestId('executions-sidebar'),
executionsEmptyList: () => cy.getByTestId('execution-list-empty'),
autoRefreshCheckBox: () => cy.getByTestId('auto-refresh-checkbox'),
executionsList: () => cy.getByTestId('current-executions-list'),
executionListItems: () => this.getters.executionsList().find('div.execution-card'),

View file

@ -1,6 +1,6 @@
import 'cypress-real-events';
import FakeTimers from '@sinonjs/fake-timers';
import type { IN8nUISettings } from 'n8n-workflow';
import type { FrontendSettings } from '@n8n/api-types';
import { WorkflowPage } from '../pages';
import {
BACKEND_BASE_URL,
@ -86,8 +86,8 @@ Cypress.Commands.add('signout', () => {
cy.getCookie(N8N_AUTH_COOKIE).should('not.exist');
});
export let settings: Partial<IN8nUISettings>;
Cypress.Commands.add('overrideSettings', (value: Partial<IN8nUISettings>) => {
export let settings: Partial<FrontendSettings>;
Cypress.Commands.add('overrideSettings', (value: Partial<FrontendSettings>) => {
settings = value;
});

View file

@ -1,7 +1,7 @@
// Load type definitions that come with Cypress module
/// <reference types="cypress" />
import type { IN8nUISettings } from 'n8n-workflow';
import type { FrontendSettings } from '@n8n/api-types';
Cypress.Keyboard.defaults({
keystrokeDelay: 0,
@ -45,7 +45,7 @@ declare global {
*/
signinAsMember(index?: number): void;
signout(): void;
overrideSettings(value: Partial<IN8nUISettings>): void;
overrideSettings(value: Partial<FrontendSettings>): void;
enableFeature(feature: string): void;
disableFeature(feature: string): void;
enableQueueMode(): void;

View file

@ -6,7 +6,7 @@ FROM --platform=linux/amd64 n8nio/base:${NODE_VERSION} AS builder
# Build the application from source
WORKDIR /src
COPY . /src
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store --mount=type=cache,id=pnpm-metadata,target=/root/.cache/pnpm/metadata pnpm install --frozen-lockfile
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store --mount=type=cache,id=pnpm-metadata,target=/root/.cache/pnpm/metadata DOCKER_BUILD=true pnpm install --frozen-lockfile
RUN pnpm build
# Delete all dev dependencies

10
lefthook.yml Normal file
View file

@ -0,0 +1,10 @@
pre-commit:
commands:
biome_check:
glob: 'packages/**/*.{js,ts,json}'
run: ./node_modules/.bin/biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files}
stage_fixed: true
prettier_check:
glob: 'packages/**/*.{vue,yml,md}'
run: ./node_modules/.bin/prettier --write --ignore-unknown --no-error-on-unmatched-pattern {staged_files}
stage_fixed: true

View file

@ -1,7 +1,7 @@
{
"folders": [
{
"path": ".",
},
],
"path": "."
}
]
}

View file

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "1.57.0",
"version": "1.59.0",
"private": true,
"engines": {
"node": ">=20.15",
@ -8,6 +8,7 @@
},
"packageManager": "pnpm@9.6.0",
"scripts": {
"prepare": "node scripts/prepare.mjs",
"preinstall": "node scripts/block-npm-install.js",
"build": "turbo run build",
"build:backend": "turbo run build:backend",
@ -17,7 +18,9 @@
"dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat",
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core",
"clean": "turbo run clean --parallel",
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",
"format": "turbo run format && node scripts/format.mjs",
"format:check": "turbo run format:check",
"lint": "turbo run lint",
"lintfix": "turbo run lintfix",
"lint:backend": "turbo run lint:backend",
@ -37,6 +40,7 @@
"worker": "./packages/cli/bin/n8n worker"
},
"devDependencies": {
"@biomejs/biome": "^1.9.0",
"@n8n_io/eslint-config": "workspace:*",
"@types/jest": "^29.5.3",
"@types/supertest": "^6.0.2",
@ -45,6 +49,7 @@
"jest-expect-message": "^1.1.3",
"jest-mock": "^29.6.2",
"jest-mock-extended": "^3.0.4",
"lefthook": "^1.7.15",
"nock": "^13.3.2",
"nodemon": "^3.0.1",
"p-limit": "^3.1.0",
@ -55,7 +60,8 @@
"tsc-alias": "^1.8.7",
"tsc-watch": "^6.0.4",
"turbo": "2.0.6",
"typescript": "*"
"typescript": "*",
"zx": "^8.1.4"
},
"pnpm": {
"onlyBuiltDependencies": [
@ -71,7 +77,7 @@
"semver": "^7.5.4",
"tslib": "^2.6.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.5.2",
"typescript": "^5.6.2",
"ws": ">=8.17.1"
},
"patchedDependencies": {

View file

@ -0,0 +1,7 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
/** @type {import('@types/eslint').ESLint.ConfigData} */
module.exports = {
extends: ['@n8n_io/eslint-config/base'],
...sharedOptions(__dirname),
};

View file

@ -0,0 +1,3 @@
## @n8n/api-types
This package contains types and schema definitions for the n8n internal API, so that these can be shared between the backend and the frontend code.

View file

@ -0,0 +1,2 @@
/** @type {import('jest').Config} */
module.exports = require('../../../jest.config');

View file

@ -0,0 +1,25 @@
{
"name": "@n8n/api-types",
"version": "0.1.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
"typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint .",
"lintfix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "echo \"No tests yet\" && exit 0"
},
"main": "dist/index.js",
"module": "src/index.ts",
"types": "dist/index.d.ts",
"files": [
"dist/**/*"
],
"devDependencies": {
"n8n-workflow": "workspace:*"
}
}

View file

@ -0,0 +1,2 @@
/** Date time in the ISO 8601 format, e.g. 2024-10-31T00:00:00.123Z */
export type Iso8601DateTimeString = string;

View file

@ -0,0 +1,172 @@
import type { ExpressionEvaluatorType, LogLevel, WorkflowSettings } from 'n8n-workflow';
export interface IVersionNotificationSettings {
enabled: boolean;
endpoint: string;
infoUrl: string;
}
export interface ITelemetryClientConfig {
url: string;
key: string;
}
export interface ITelemetrySettings {
enabled: boolean;
config?: ITelemetryClientConfig;
}
export type AuthenticationMethod = 'email' | 'ldap' | 'saml';
export interface IUserManagementSettings {
quota: number;
showSetupOnFirstLoad?: boolean;
smtpSetup: boolean;
authenticationMethod: AuthenticationMethod;
}
export interface FrontendSettings {
isDocker?: boolean;
databaseType: 'sqlite' | 'mariadb' | 'mysqldb' | 'postgresdb';
endpointForm: string;
endpointFormTest: string;
endpointFormWaiting: string;
endpointWebhook: string;
endpointWebhookTest: string;
saveDataErrorExecution: WorkflowSettings.SaveDataExecution;
saveDataSuccessExecution: WorkflowSettings.SaveDataExecution;
saveManualExecutions: boolean;
saveExecutionProgress: boolean;
executionTimeout: number;
maxExecutionTimeout: number;
workflowCallerPolicyDefaultOption: WorkflowSettings.CallerPolicy;
oauthCallbackUrls: {
oauth1: string;
oauth2: string;
};
timezone: string;
urlBaseWebhook: string;
urlBaseEditor: string;
versionCli: string;
nodeJsVersion: string;
concurrency: number;
authCookie: {
secure: boolean;
};
binaryDataMode: 'default' | 'filesystem' | 's3';
releaseChannel: 'stable' | 'beta' | 'nightly' | 'dev';
n8nMetadata?: {
userId?: string;
[key: string]: string | number | undefined;
};
versionNotifications: IVersionNotificationSettings;
instanceId: string;
telemetry: ITelemetrySettings;
posthog: {
enabled: boolean;
apiHost: string;
apiKey: string;
autocapture: boolean;
disableSessionRecording: boolean;
debug: boolean;
};
personalizationSurveyEnabled: boolean;
defaultLocale: string;
userManagement: IUserManagementSettings;
sso: {
saml: {
loginLabel: string;
loginEnabled: boolean;
};
ldap: {
loginLabel: string;
loginEnabled: boolean;
};
};
publicApi: {
enabled: boolean;
latestVersion: number;
path: string;
swaggerUi: {
enabled: boolean;
};
};
workflowTagsDisabled: boolean;
logLevel: LogLevel;
hiringBannerEnabled: boolean;
previewMode: boolean;
templates: {
enabled: boolean;
host: string;
};
missingPackages?: boolean;
executionMode: 'regular' | 'queue';
pushBackend: 'sse' | 'websocket';
communityNodesEnabled: boolean;
aiAssistant: {
enabled: boolean;
};
deployment: {
type: string;
};
isNpmAvailable: boolean;
allowedModules: {
builtIn?: string[];
external?: string[];
};
enterprise: {
sharing: boolean;
ldap: boolean;
saml: boolean;
logStreaming: boolean;
advancedExecutionFilters: boolean;
variables: boolean;
sourceControl: boolean;
auditLogs: boolean;
externalSecrets: boolean;
showNonProdBanner: boolean;
debugInEditor: boolean;
binaryDataS3: boolean;
workflowHistory: boolean;
workerView: boolean;
advancedPermissions: boolean;
projects: {
team: {
limit: number;
};
};
};
hideUsagePage: boolean;
license: {
planName?: string;
consumerId: string;
environment: 'development' | 'production' | 'staging';
};
variables: {
limit: number;
};
expressions: {
evaluator: ExpressionEvaluatorType;
};
mfa: {
enabled: boolean;
};
banners: {
dismissed: string[];
};
ai: {
enabled: boolean;
};
workflowHistory: {
pruneTime: number;
licensePruneTime: number;
};
pruning: {
isEnabled: boolean;
maxAge: number;
maxCount: number;
};
security: {
blockFileAccessToN8nFiles: boolean;
};
}

View file

@ -0,0 +1,8 @@
export type * from './datetime';
export type * from './push';
export type * from './scaling';
export type * from './frontend-settings';
export type * from './user';
export type { Collaborator } from './push/collaboration';
export type { SendWorkerStatusMessage } from './push/worker';

View file

@ -0,0 +1,17 @@
import type { Iso8601DateTimeString } from '../datetime';
import type { MinimalUser } from '../user';
export type Collaborator = {
user: MinimalUser;
lastSeen: Iso8601DateTimeString;
};
type CollaboratorsChanged = {
type: 'collaboratorsChanged';
data: {
workflowId: string;
collaborators: Collaborator[];
};
};
export type CollaborationPushMessage = CollaboratorsChanged;

View file

@ -0,0 +1,9 @@
type SendConsoleMessage = {
type: 'sendConsoleMessage';
data: {
source: string;
messages: unknown[];
};
};
export type DebugPushMessage = SendConsoleMessage;

View file

@ -0,0 +1,53 @@
import type { IRun, ITaskData, WorkflowExecuteMode } from 'n8n-workflow';
type ExecutionStarted = {
type: 'executionStarted';
data: {
executionId: string;
mode: WorkflowExecuteMode;
startedAt: Date;
workflowId: string;
workflowName?: string;
retryOf?: string;
};
};
type ExecutionFinished = {
type: 'executionFinished';
data: {
executionId: string;
data: IRun;
retryOf?: string;
};
};
type ExecutionRecovered = {
type: 'executionRecovered';
data: {
executionId: string;
};
};
type NodeExecuteBefore = {
type: 'nodeExecuteBefore';
data: {
executionId: string;
nodeName: string;
};
};
type NodeExecuteAfter = {
type: 'nodeExecuteAfter';
data: {
executionId: string;
nodeName: string;
data: ITaskData;
};
};
export type ExecutionPushMessage =
| ExecutionStarted
| ExecutionFinished
| ExecutionRecovered
| NodeExecuteBefore
| NodeExecuteAfter;

View file

@ -0,0 +1,21 @@
type NodeTypeData = {
name: string;
version: number;
};
type ReloadNodeType = {
type: 'reloadNodeType';
data: NodeTypeData;
};
type RemoveNodeType = {
type: 'removeNodeType';
data: NodeTypeData;
};
type NodeDescriptionUpdated = {
type: 'nodeDescriptionUpdated';
data: {};
};
export type HotReloadPushMessage = ReloadNodeType | RemoveNodeType | NodeDescriptionUpdated;

View file

@ -0,0 +1,20 @@
import type { ExecutionPushMessage } from './execution';
import type { WorkflowPushMessage } from './workflow';
import type { HotReloadPushMessage } from './hot-reload';
import type { WorkerPushMessage } from './worker';
import type { WebhookPushMessage } from './webhook';
import type { CollaborationPushMessage } from './collaboration';
import type { DebugPushMessage } from './debug';
export type PushMessage =
| ExecutionPushMessage
| WorkflowPushMessage
| HotReloadPushMessage
| WebhookPushMessage
| WorkerPushMessage
| CollaborationPushMessage
| DebugPushMessage;
export type PushType = PushMessage['type'];
export type PushPayload<T extends PushType> = Extract<PushMessage, { type: T }>['data'];

View file

@ -0,0 +1,17 @@
type TestWebhookDeleted = {
type: 'testWebhookDeleted';
data: {
executionId?: string;
workflowId: string;
};
};
type TestWebhookReceived = {
type: 'testWebhookReceived';
data: {
executionId: string;
workflowId: string;
};
};
export type WebhookPushMessage = TestWebhookDeleted | TestWebhookReceived;

View file

@ -0,0 +1,11 @@
import type { WorkerStatus } from '../scaling';
export type SendWorkerStatusMessage = {
type: 'sendWorkerStatusMessage';
data: {
workerId: string;
status: WorkerStatus;
};
};
export type WorkerPushMessage = SendWorkerStatusMessage;

View file

@ -0,0 +1,26 @@
type WorkflowActivated = {
type: 'workflowActivated';
data: {
workflowId: string;
};
};
type WorkflowFailedToActivate = {
type: 'workflowFailedToActivate';
data: {
workflowId: string;
errorMessage: string;
};
};
type WorkflowDeactivated = {
type: 'workflowDeactivated';
data: {
workflowId: string;
};
};
export type WorkflowPushMessage =
| WorkflowActivated
| WorkflowFailedToActivate
| WorkflowDeactivated;

View file

@ -0,0 +1,30 @@
import type { ExecutionStatus, WorkflowExecuteMode } from 'n8n-workflow';
export type RunningJobSummary = {
executionId: string;
workflowId: string;
workflowName: string;
mode: WorkflowExecuteMode;
startedAt: Date;
retryOf: string;
status: ExecutionStatus;
};
export type WorkerStatus = {
workerId: string;
runningJobsSummary: RunningJobSummary[];
freeMem: number;
totalMem: number;
uptime: number;
loadAvg: number[];
cpus: string;
arch: string;
platform: NodeJS.Platform;
hostname: string;
interfaces: Array<{
family: 'IPv4' | 'IPv6';
address: string;
internal: boolean;
}>;
version: string;
};

View file

@ -0,0 +1,6 @@
export type MinimalUser = {
id: string;
email: string;
firstName: string;
lastName: string;
};

View file

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

View file

@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"types": ["node", "jest"],
"baseUrl": "src",
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo"
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}

View file

@ -12,9 +12,10 @@ module.exports = {
project: './tsconfig.json',
},
ignorePatterns: ['scenarios/**', 'scripts/**'],
ignorePatterns: ['scenarios/**'],
rules: {
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
'n8n-local-rules/no-plain-errors': 'off',
complexity: 'error',
},

View file

@ -33,6 +33,7 @@ COPY --chown=node:node ./packages/@n8n/benchmark/package.json /app/packages/@n8n
COPY --chown=node:node ./patches /app/patches
COPY --chown=node:node ./scripts /app/scripts
ENV DOCKER_BUILD=true
RUN pnpm install --frozen-lockfile
# TS config files

View file

@ -0,0 +1,7 @@
{
"$schema": "../node_modules/@biomejs/biome/configuration_schema.json",
"extends": ["../../../biome.jsonc"],
"files": {
"ignore": ["scripts/mock-api/**"]
}
}

View file

@ -25,7 +25,7 @@ resource "azurerm_dedicated_host_group" "main" {
automatic_placement_enabled = false
zone = 1
tags = local.common_tags
tags = local.common_tags
}
resource "azurerm_dedicated_host" "hosts" {
@ -35,7 +35,7 @@ resource "azurerm_dedicated_host" "hosts" {
sku_name = var.host_size_family
platform_fault_domain = 0
tags = local.common_tags
tags = local.common_tags
}
# VM

View file

@ -5,3 +5,7 @@ output "vm_name" {
output "ip" {
value = azurerm_public_ip.main.ip_address
}
output "ssh_username" {
value = azurerm_linux_virtual_machine.main.admin_username
}

View file

@ -118,19 +118,9 @@ resource "azurerm_linux_virtual_machine" "main" {
version = "latest"
}
identity {
type = "SystemAssigned"
}
tags = var.tags
}
resource "azurerm_virtual_machine_extension" "entra_login" {
name = "AADSSHLoginForLinux"
virtual_machine_id = azurerm_linux_virtual_machine.main.id
publisher = "Microsoft.Azure.ActiveDirectory"
type = "AADSSHLoginForLinux"
type_handler_version = "1.0"
identity {
type = "SystemAssigned"
}
tags = var.tags
}

View file

@ -1,3 +1,16 @@
output "vm_name" {
value = module.test_vm.vm_name
}
output "ip" {
value = module.test_vm.ip
}
output "ssh_username" {
value = module.test_vm.ssh_username
}
output "ssh_private_key" {
value = tls_private_key.ssh_key.private_key_pem
sensitive = true
}

View file

@ -29,6 +29,6 @@ locals {
Id = "N8nBenchmark"
Terraform = "true"
Owner = "Catalysts"
CreatedAt = timestamp()
CreatedAt = timestamp()
}
}

View file

@ -1,10 +1,12 @@
{
"name": "@n8n/n8n-benchmark",
"version": "1.1.0",
"version": "1.3.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",
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint .",
"lintfix": "eslint . --fix",
"start": "./bin/n8n-benchmark",
@ -13,7 +15,8 @@
"benchmark": "zx scripts/run.mjs",
"benchmark-in-cloud": "pnpm benchmark --env cloud",
"benchmark-locally": "pnpm benchmark --env local",
"destroy-cloud-env": "zx scripts/destroyCloudEnv.mjs",
"provision-cloud-env": "zx scripts/provision-cloud-env.mjs",
"destroy-cloud-env": "zx scripts/destroy-cloud-env.mjs",
"watch": "concurrently \"tsc -w -p tsconfig.build.json\" \"tsc-alias -w -p tsconfig.build.json\""
},
"engines": {
@ -32,8 +35,8 @@
"dependencies": {
"@oclif/core": "4.0.7",
"axios": "catalog:",
"convict": "6.2.4",
"dotenv": "8.6.0",
"nanoid": "catalog:",
"zx": "^8.1.4"
},
"devDependencies": {

View file

@ -0,0 +1,67 @@
{
"createdAt": "2024-09-03T11:51:56.540Z",
"updatedAt": "2024-09-03T12:22:21.000Z",
"name": "Binary Data",
"active": true,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "binary-files-benchmark",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [0, 0],
"id": "bfe19f12-3655-440f-be5c-8d71665c6353",
"name": "Webhook",
"webhookId": "109d7b13-93ad-42b0-a9ce-ca49e1817b35"
},
{
"parameters": { "respondWith": "binary", "options": {} },
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [740, 0],
"id": "cd957c9b-6b7a-4423-aac3-6df4d8bb571e",
"name": "Respond to Webhook"
},
{
"parameters": {
"operation": "write",
"fileName": "=file-{{ Date.now() }}-{{ Math.random() }}.js",
"dataPropertyName": "file",
"options": {}
},
"type": "n8n-nodes-base.readWriteFile",
"typeVersion": 1,
"position": [260, 0],
"id": "f2ce4709-7697-4bc6-8eca-6c222485297a",
"name": "Write File to Disk"
},
{
"parameters": { "fileSelector": "={{ $json.fileName }}", "options": {} },
"type": "n8n-nodes-base.readWriteFile",
"typeVersion": 1,
"position": [500, 0],
"id": "198e8a6c-81a3-4b34-b099-501961a02006",
"name": "Read File from Disk"
}
],
"connections": {
"Webhook": { "main": [[{ "node": "Write File to Disk", "type": "main", "index": 0 }]] },
"Write File to Disk": {
"main": [[{ "node": "Read File from Disk", "type": "main", "index": 0 }]]
},
"Read File from Disk": {
"main": [[{ "node": "Respond to Webhook", "type": "main", "index": 0 }]]
}
},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": null,
"pinData": {},
"versionId": "8dd197c0-d1ea-43c3-9f88-9d11e7b081a0",
"triggerCount": 1,
"tags": []
}

View file

@ -0,0 +1,7 @@
{
"$schema": "../scenario.schema.json",
"name": "BinaryData",
"description": "Send a binary file to a webhook, write it to FS, read it from FS and receive it back",
"scenarioData": { "workflowFiles": ["binary-data.json"] },
"scriptPath": "binary-data.script.js"
}

View file

@ -0,0 +1,22 @@
import http from 'k6/http';
import { check } from 'k6';
const apiBaseUrl = __ENV.API_BASE_URL;
const file = open(__ENV.SCRIPT_FILE_PATH, 'b');
const filename = String(__ENV.SCRIPT_FILE_PATH).split('/').pop();
export default function () {
const data = {
filename,
file: http.file(file, filename, 'application/javascript'),
};
const res = http.post(`${apiBaseUrl}/webhook/binary-files-benchmark`, data);
check(res, {
'is status 200': (r) => r.status === 200,
'has correct content type': (r) =>
r.headers['Content-Type'] === 'application/javascript; charset=utf-8',
});
}

View file

@ -0,0 +1,213 @@
{
"createdAt": "2024-09-04T07:18:29.011Z",
"updatedAt": "2024-09-04T07:27:58.000Z",
"id": "rUXzWNGsUDUmgaFS",
"name": "HTTP Request",
"active": false,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "benchmark-http-node",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [-60, 20],
"id": "f11378b4-5f28-4a6c-9332-5878342cd3cf",
"name": "Webhook",
"webhookId": "c40014cc-4d64-4fcf-8c13-9e94b6792756"
},
{
"parameters": {
"respondWith": "allIncomingItems",
"options": {}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [1060, 20],
"id": "f42552c7-9c6e-4616-b9d5-ac79445ef4ed",
"name": "Respond to Webhook"
},
{
"parameters": {
"url": "http://mockapi:8080/users/clair.bahringer/received_events/public",
"options": {
"response": {
"response": {
"fullResponse": true
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [300, -180],
"id": "20de816e-0fbe-4e28-bc53-f508a2dda117",
"name": "Mock public received events"
},
{
"parameters": {
"url": "http://mockapi:8080/repos/udke6pujoywnagxkcvab2riw23khzn2tibo2vincws32qexb50ey7h97d42vnzyol0rxypgsg4pomsf7sgnmdaihstljw8edcijrwmy7mfi76yif19c4/47i31dh737el215j62ts2f2782nw3ss26rul3s8jw13u3vu0xm349a5hyay5asmwnlnf7nx8p9h4g62so6s1cis7xv9puj5j98t4m980sbe2455fn1obccjl/events",
"options": {
"response": {
"response": {
"fullResponse": true
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [300, 20],
"id": "083e02b3-a257-49a8-8f7d-42222cb9194c",
"name": "Mock repository events"
},
{
"parameters": {
"url": "http://mockapi:8080/orgs/g02pp066qoyithcjevhd6m1wfii3c4x51k39n9apybljhx69/events",
"options": {
"response": {
"response": {
"fullResponse": true
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [300, 220],
"id": "f4c3b5d2-0257-4883-a585-ade4c3a1082c",
"name": "Mock organization events"
},
{
"parameters": {
"numberInputs": 3
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3,
"position": [600, 20],
"id": "273985b7-b0ae-4cde-bbe9-7b3e4b29fe61",
"name": "Merge"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "89608adb-f487-416f-a7d8-3ebb1f7b50e5",
"name": "statusCode",
"value": "={{ $json.statusCode }}",
"type": "number"
}
]
},
"options": {}
},
"id": "231275a7-44e7-47bb-8ccf-fe62dc48356b",
"name": "Select statusCode",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [820, 20]
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Mock public received events",
"type": "main",
"index": 0
},
{
"node": "Mock repository events",
"type": "main",
"index": 0
},
{
"node": "Mock organization events",
"type": "main",
"index": 0
}
]
]
},
"Mock public received events": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 0
}
]
]
},
"Mock repository events": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 1
}
]
]
},
"Mock organization events": {
"main": [
[
{
"node": "Merge",
"type": "main",
"index": 2
}
]
]
},
"Merge": {
"main": [
[
{
"node": "Select statusCode",
"type": "main",
"index": 0
}
]
]
},
"Select statusCode": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": null,
"pinData": {
"Webhook": [
{
"json": {
"headers": { "host": "localhost:5678", "user-agent": "curl/8.6.0", "accept": "*/*" },
"params": {},
"query": {},
"body": {},
"webhookUrl": "http://localhost:5678/webhook-test/benchmark-http-node",
"executionMode": "test"
}
}
]
},
"versionId": "9fa91e54-e73a-4a34-b781-d64f2b02f333",
"triggerCount": 0,
"tags": []
}

View file

@ -0,0 +1,7 @@
{
"$schema": "../scenario.schema.json",
"name": "HttpNode",
"description": "Webhook -> 3x HTTP request to a mock API -> Merge -> Respond to Webhook. Requires a mock API running at http://mockapi:8080",
"scenarioData": { "workflowFiles": ["http-node.json"] },
"scriptPath": "http-node.script.js"
}

View file

@ -0,0 +1,24 @@
import http from 'k6/http';
import { check } from 'k6';
const apiBaseUrl = __ENV.API_BASE_URL;
export default function () {
const res = http.post(`${apiBaseUrl}/webhook/benchmark-http-node`);
check(res, {
'is status 200': (r) => r.status === 200,
'http requests were OK': (r) => {
if (r.status !== 200) return false;
try {
// Response body is an array of the request status codes made with HttpNodes
const body = JSON.parse(r.body);
return Array.isArray(body) ? body.every((request) => request.statusCode === 200) : false;
} catch (error) {
console.error('Error parsing response body: ', error);
return false;
}
},
});
}

View file

@ -0,0 +1,97 @@
{
"createdAt": "2024-08-06T12:19:51.268Z",
"updatedAt": "2024-08-06T12:20:45.000Z",
"name": "JS Code Node Once For Each",
"active": true,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "code-node-benchmark",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [0, 0],
"id": "849350b3-4212-4416-a462-1cf331157d37",
"name": "Webhook",
"webhookId": "34ca1895-ccf4-4a4a-8bb8-a042f5edb567"
},
{
"parameters": {
"respondWith": "allIncomingItems",
"options": {}
},
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [660, 0],
"id": "f0660aa1-8a65-490f-b5cd-f8d134070c13",
"name": "Respond to Webhook"
},
{
"parameters": {
"category": "randomData",
"randomDataCount": 5
},
"type": "n8n-nodes-base.debugHelper",
"typeVersion": 1,
"position": [220, 0],
"id": "50f1efe8-bd2d-4061-9f51-b38c0e3daeb2",
"name": "DebugHelper"
},
{
"parameters": {
"mode": "runOnceForEachItem",
"jsCode": "// Add new field\n$input.item.json.age = 10 + Math.floor(Math.random() * 30);\n// Mutate existing field\n$input.item.json.password = $input.item.json.password.split('').map(() => '*').join(\"\")\n// Remove field\ndelete $input.item.json.lastname\n// New object field\nconst emailParts = $input.item.json.email.split(\"@\")\n$input.item.json.emailData = {\n user: emailParts[0],\n domain: emailParts[1]\n}\n\nreturn $input.item;"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [440, 0],
"id": "f9f2f865-e228-403d-8e47-72308359e207",
"name": "OnceForEachItemJSCode"
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "DebugHelper",
"type": "main",
"index": 0
}
]
]
},
"DebugHelper": {
"main": [
[
{
"node": "OnceForEachItemJSCode",
"type": "main",
"index": 0
}
]
]
},
"OnceForEachItemJSCode": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"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": "CodeNodeJsOnceForEach",
"description": "A JS Code Node that runs once for each item and adds, modifies and removes properties. The data of 5 items is generated using DebugHelper Node, and returned with RespondToWebhook Node.",
"scenarioData": { "workflowFiles": ["js-code-node-once-for-each.json"] },
"scriptPath": "js-code-node-once-for-each.script.js"
}

View file

@ -0,0 +1,22 @@
import http from 'k6/http';
import { check } from 'k6';
const apiBaseUrl = __ENV.API_BASE_URL;
export default function () {
const res = http.post(`${apiBaseUrl}/webhook/code-node-benchmark`, {});
check(res, {
'is status 200': (r) => r.status === 200,
'has items in response': (r) => {
if (r.status !== 200) return false;
try {
const body = JSON.parse(r.body);
return Array.isArray(body) ? body.length === 5 : false;
} catch (error) {
console.error('Error parsing response body: ', error);
return false;
}
},
});
}

View file

@ -0,0 +1,91 @@
{
"createdAt": "2024-09-03T11:30:26.333Z",
"updatedAt": "2024-09-03T11:42:52.000Z",
"name": "Set Node Expressions",
"active": false,
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "set-expressions-benchmark",
"responseMode": "responseNode",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [40, 0],
"id": "5babc228-2b89-48cb-8337-28416e867874",
"name": "Webhook",
"webhookId": "f6f1750d-b734-496f-afe8-26e8e393ca87"
},
{
"parameters": { "respondWith": "allIncomingItems", "options": {} },
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [640, 0],
"id": "4146a3fb-403c-4cfc-9d38-8af4d16a8440",
"name": "Respond to Webhook"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "48c46098-f411-41f7-8f0a-1da372340a4e",
"name": "oneToOneCopy",
"value": "={{ $json.headers.host }}",
"type": "string"
},
{
"id": "5d90808b-1c1a-4065-ac51-6d61bd03e564",
"name": "={{ $json.headers['user-agent'].slice(0, 4) }}",
"value": "Set key with expression",
"type": "string"
},
{
"id": "8a74ac24-1f43-43ba-969d-87bfd2f401ce",
"name": "Multiple variables",
"value": "={{ $json.executionMode + ' ' + $json.webhookUrl }}",
"type": "string"
},
{
"id": "93eba201-79d9-4305-a246-f9c8ec50ebab",
"name": "Static value",
"value": 42,
"type": "number"
},
{
"id": "0470a712-c795-44ab-9dcc-05a3f67698bb",
"name": "Object",
"value": "={{ $json.headers }}",
"type": "object"
},
{
"id": "eb671167-da14-4b55-8eea-31ab7bedae10",
"name": "Array",
"value": "={{ Object.values($json.headers) }}",
"type": "array"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [360, 0],
"id": "0cb5e82d-f61e-4d91-8fa9-365e382a4d75",
"name": "Edit Fields"
}
],
"connections": {
"Webhook": { "main": [[{ "node": "Edit Fields", "type": "main", "index": 0 }]] },
"Edit Fields": { "main": [[{ "node": "Respond to Webhook", "type": "main", "index": 0 }]] }
},
"settings": { "executionOrder": "v1" },
"staticData": null,
"meta": null,
"pinData": {},
"versionId": "04fd543e-3923-4092-8c2b-2b4262ccbb38",
"triggerCount": 0,
"tags": []
}

View file

@ -0,0 +1,7 @@
{
"$schema": "../scenario.schema.json",
"name": "SetNodeExpressions",
"description": "Expressions in a Set node",
"scenarioData": { "workflowFiles": ["set-node-expressions.json"] },
"scriptPath": "set-node-expressions.script.js"
}

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.post(`${apiBaseUrl}/webhook/set-expressions-benchmark`, {});
check(res, {
'is status 200': (r) => r.status === 200,
});
}

View file

@ -2,6 +2,6 @@
"$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"
"scenarioData": { "workflowFiles": ["single-webhook.json"] },
"scriptPath": "single-webhook.script.ts"
}

View file

@ -34,10 +34,20 @@ else
sudo mkfs.xfs /dev/sdc1
sudo partprobe /dev/sdc1
sudo mount /dev/sdc1 /n8n
sudo chown -R "$CURRENT_USER":"$CURRENT_USER" /n8n
fi
# Allow the current user to write to the data disk
sudo chmod a+rw /n8n
### Remove unneeded dependencies
# TTY
sudo systemctl disable getty@tty1.service
sudo systemctl disable serial-getty@ttyS0.service
# Snap
sudo systemctl disable snapd.service
sudo apt remove snapd
# Unattended upgrades
sudo systemctl disable unattended-upgrades.service
# Cron
sudo systemctl disable cron.service
# Include nodejs v20 repository
curl -fsSL https://deb.nodesource.com/setup_20.x -o nodesource_setup.sh

View file

@ -0,0 +1,37 @@
// @ts-check
import { $ } from 'zx';
export class SshClient {
/**
*
* @param {{ privateKeyPath: string; ip: string; username: string; verbose?: boolean }} param0
*/
constructor({ privateKeyPath, ip, username, verbose = false }) {
this.verbose = verbose;
this.privateKeyPath = privateKeyPath;
this.ip = ip;
this.username = username;
this.$$ = $({
verbose,
});
}
/**
* @param {string} command
* @param {{ verbose?: boolean }} [options]
*/
async ssh(command, options = {}) {
const $$ = options?.verbose ? $({ verbose: true }) : this.$$;
const target = `${this.username}@${this.ip}`;
await $$`ssh -i ${this.privateKeyPath} -o StrictHostKeyChecking=accept-new ${target} ${command}`;
}
async scp(source, destination) {
const target = `${this.username}@${this.ip}:${destination}`;
await this
.$$`scp -i ${this.privateKeyPath} -o StrictHostKeyChecking=accept-new ${source} ${target}`;
}
}

View file

@ -1,28 +0,0 @@
// @ts-check
import { $ } from 'zx';
export class SshClient {
/**
*
* @param {{ vmName: string; resourceGroupName: string; verbose?: boolean }} param0
*/
constructor({ vmName, resourceGroupName, verbose = false }) {
this.vmName = vmName;
this.resourceGroupName = resourceGroupName;
this.verbose = verbose;
this.$$ = $({
verbose,
});
}
/**
* @param {string} command
* @param {{ verbose?: boolean }} [options]
*/
async ssh(command, options = {}) {
const $$ = options?.verbose ? $({ verbose: true }) : this.$$;
await $$`az ssh vm -n ${this.vmName} -g ${this.resourceGroupName} --yes -- -o StrictHostKeyChecking=accept-new ${command}`;
}
}

View file

@ -18,28 +18,40 @@ export class TerraformClient {
}
/**
* @typedef {Object} BenchmarkEnv
* @property {string} vmName
*
* @returns {Promise<BenchmarkEnv>}
* Provisions the environment
*/
async provisionEnvironment() {
console.log('Provisioning cloud environment...');
await this.$$`terraform init`;
await this.$$`terraform apply -input=false -auto-approve`;
}
/**
* @typedef {Object} BenchmarkEnv
* @property {string} vmName
* @property {string} ip
* @property {string} sshUsername
* @property {string} sshPrivateKeyPath
*
* @returns {Promise<BenchmarkEnv>}
*/
async getTerraformOutputs() {
const privateKeyName = await this.extractPrivateKey();
return {
ip: await this.getTerraformOutput('ip'),
sshUsername: await this.getTerraformOutput('ssh_username'),
sshPrivateKeyPath: path.join(paths.infraCodeDir, privateKeyName),
vmName: await this.getTerraformOutput('vm_name'),
};
}
async destroyEnvironment() {
if (!fs.existsSync(paths.terraformStateFile)) {
console.log('No cloud environment to destroy. Skipping...');
return;
}
hasTerraformState() {
return fs.existsSync(paths.terraformStateFile);
}
async destroyEnvironment() {
console.log('Destroying cloud environment...');
await this.$$`terraform destroy -input=false -auto-approve`;
@ -49,4 +61,11 @@ export class TerraformClient {
const output = await this.$$`terraform output -raw ${key}`;
return output.stdout.trim();
}
async extractPrivateKey() {
await this.$$`terraform output -raw ssh_private_key > privatekey.pem`;
await this.$$`chmod 600 privatekey.pem`;
return 'privatekey.pem';
}
}

View file

@ -1,52 +1,43 @@
#!/usr/bin/env zx
/**
* Script that deletes all resources created by the benchmark environment
* and that are older than 2 hours.
* Script that deletes all resources created by the benchmark environment.
*
* Even tho the environment is provisioned using terraform, the terraform
* state is not persisted. Hence we can't use terraform to delete the resources.
* We could store the state to a storage account, but then we wouldn't be able
* to spin up new envs on-demand. Hence this design.
*
* Usage:
* zx scripts/deleteCloudEnv.mjs
* This scripts tries to delete resources created by Terraform. If Terraform
* state file is not found, it will try to delete resources using Azure CLI.
* The terraform state is not persisted, so we want to support both cases.
*/
// @ts-check
import { $ } from 'zx';
import { $, minimist } from 'zx';
import { TerraformClient } from './clients/terraform-client.mjs';
const EXPIRE_TIME_IN_H = 2;
const EXPIRE_TIME_IN_MS = EXPIRE_TIME_IN_H * 60 * 60 * 1000;
const RESOURCE_GROUP_NAME = 'n8n-benchmarking';
const args = minimist(process.argv.slice(3), {
boolean: ['debug'],
});
const isVerbose = !!args.debug;
async function main() {
const terraformClient = new TerraformClient({ isVerbose });
if (terraformClient.hasTerraformState()) {
await terraformClient.destroyEnvironment();
} else {
await destroyUsingAz();
}
}
async function destroyUsingAz() {
const resourcesResult =
await $`az resource list --resource-group ${RESOURCE_GROUP_NAME} --query "[?tags.Id == 'N8nBenchmark'].{id:id, createdAt:tags.CreatedAt}" -o json`;
const resources = JSON.parse(resourcesResult.stdout);
const now = Date.now();
const resourcesToDelete = resources
.filter((resource) => {
if (resource.createdAt === undefined) {
return true;
}
const createdAt = new Date(resource.createdAt);
const resourceExpiredAt = createdAt.getTime() + EXPIRE_TIME_IN_MS;
return now > resourceExpiredAt;
})
.map((resource) => resource.id);
const resourcesToDelete = resources.map((resource) => resource.id);
if (resourcesToDelete.length === 0) {
if (resources.length === 0) {
console.log('No resources found in the resource group.');
} else {
console.log(
`Found ${resources.length} resources in the resource group, but none are older than ${EXPIRE_TIME_IN_H} hours.`,
);
}
console.log('No resources found in the resource group.');
return;
}
@ -87,4 +78,9 @@ async function deleteById(id) {
}
}
main();
main().catch((error) => {
console.error('An error occurred destroying cloud env:');
console.error(error);
process.exit(1);
});

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,59 @@
services:
mockapi:
image: wiremock/wiremock:3.9.1
ports:
- '8088:8080'
volumes:
- ${MOCK_API_DATA_PATH}/mappings:/home/wiremock/mappings
postgres:
image: postgres:16
restart: always
user: root:root
environment:
- POSTGRES_DB=n8n
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ${RUN_DIR}/postgres:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 5s
timeout: 5s
retries: 5
n8n:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
user: root:root
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
ports:
- 5678:5678
volumes:
- ${RUN_DIR}/n8n:/n8n
depends_on:
postgres:
condition: service_healthy
mockapi:
condition: service_started
healthcheck:
test: ['CMD-SHELL', 'wget --spider -q http://n8n:5678/healthz || exit 1']
interval: 5s
timeout: 5s
retries: 10
benchmark:
image: ghcr.io/n8n-io/n8n-benchmark:${N8N_BENCHMARK_VERSION:-latest}
depends_on:
n8n:
condition: service_healthy
environment:
- N8N_BASE_URL=http://n8n:5678
- K6_API_TOKEN=${K6_API_TOKEN}
- BENCHMARK_RESULT_WEBHOOK_URL=${BENCHMARK_RESULT_WEBHOOK_URL}
- BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER=${BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER}

View file

@ -0,0 +1,15 @@
#!/usr/bin/env zx
import path from 'path';
import { fs } from 'zx';
/**
* Creates the needed directories so the permissions get set correctly.
*/
export function setup({ runDir }) {
const neededDirs = ['n8n', 'postgres'];
for (const dir of neededDirs) {
fs.ensureDirSync(path.join(runDir, dir));
}
}

View file

@ -0,0 +1,196 @@
services:
mockapi:
image: wiremock/wiremock:3.9.1
ports:
- '8088:8080'
volumes:
- ${MOCK_API_DATA_PATH}/mappings:/home/wiremock/mappings
redis:
image: redis:6-alpine
restart: always
ports:
- 6379:6379
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 1s
timeout: 3s
postgres:
image: postgres:16
restart: always
environment:
- POSTGRES_DB=n8n
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ${RUN_DIR}/postgres:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 5s
timeout: 5s
retries: 10
n8n_worker1:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n/worker1
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- N8N_LICENSE_CERT=${N8N_LICENSE_CERT}
- N8N_LICENSE_ACTIVATION_KEY=${N8N_LICENSE_ACTIVATION_KEY}
- N8N_LICENSE_TENANT_ID=${N8N_LICENSE_TENANT_ID}
# Scaling mode config
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- QUEUE_HEALTH_CHECK_ACTIVE=true
- N8N_CONCURRENCY_PRODUCTION_LIMIT=10
# DB config
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
command: worker
volumes:
- ${RUN_DIR}/n8n-worker1:/n8n
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'wget --spider -q http://localhost:5678/healthz || exit 1']
interval: 5s
timeout: 5s
retries: 10
n8n_worker2:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n/worker2
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- N8N_LICENSE_CERT=${N8N_LICENSE_CERT}
- N8N_LICENSE_ACTIVATION_KEY=${N8N_LICENSE_ACTIVATION_KEY}
- N8N_LICENSE_TENANT_ID=${N8N_LICENSE_TENANT_ID}
# Scaling mode config
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- QUEUE_HEALTH_CHECK_ACTIVE=true
- N8N_CONCURRENCY_PRODUCTION_LIMIT=10
# DB config
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
command: worker
volumes:
- ${RUN_DIR}/n8n-worker2:/n8n
depends_on:
# We let the worker 1 start first so it can run the DB migrations
n8n_worker1:
condition: service_healthy
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'wget --spider -q http://localhost:5678/healthz || exit 1']
interval: 5s
timeout: 5s
retries: 10
n8n_main2:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- N8N_LICENSE_CERT=${N8N_LICENSE_CERT}
- N8N_LICENSE_ACTIVATION_KEY=${N8N_LICENSE_ACTIVATION_KEY}
- N8N_LICENSE_TENANT_ID=${N8N_LICENSE_TENANT_ID}
# Scaling mode config
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- N8N_MULTI_MAIN_SETUP_ENABLED=true
# DB config
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
volumes:
- ${RUN_DIR}/n8n-main2:/n8n
depends_on:
n8n_worker1:
condition: service_healthy
n8n_worker2:
condition: service_healthy
postgres:
condition: service_healthy
redis:
condition: service_healthy
mockapi:
condition: service_started
healthcheck:
test: ['CMD-SHELL', 'wget --spider -q http://n8n_main2:5678/healthz || exit 1']
interval: 5s
timeout: 5s
retries: 10
n8n_main1:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
- N8N_LICENSE_CERT=${N8N_LICENSE_CERT}
- N8N_LICENSE_ACTIVATION_KEY=${N8N_LICENSE_ACTIVATION_KEY}
- N8N_LICENSE_TENANT_ID=${N8N_LICENSE_TENANT_ID}
# Scaling mode config
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- N8N_MULTI_MAIN_SETUP_ENABLED=true
# DB config
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
volumes:
- ${RUN_DIR}/n8n-main1:/n8n
depends_on:
n8n_worker1:
condition: service_healthy
n8n_worker2:
condition: service_healthy
postgres:
condition: service_healthy
redis:
condition: service_healthy
mockapi:
condition: service_started
healthcheck:
test: ['CMD-SHELL', 'wget --spider -q http://n8n_main1:5678/healthz || exit 1']
interval: 5s
timeout: 5s
retries: 10
# Load balancer that acts as an entry point for n8n
n8n:
image: nginx:latest
ports:
- '5678:80'
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
depends_on:
n8n_main1:
condition: service_healthy
n8n_main2:
condition: service_healthy
benchmark:
image: ghcr.io/n8n-io/n8n-benchmark:${N8N_BENCHMARK_VERSION:-latest}
depends_on:
- n8n
environment:
- N8N_BASE_URL=http://n8n:80
- K6_API_TOKEN=${K6_API_TOKEN}
- BENCHMARK_RESULT_WEBHOOK_URL=${BENCHMARK_RESULT_WEBHOOK_URL}
- BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER=${BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER}

View file

@ -0,0 +1,20 @@
events {}
http {
upstream backend {
server n8n_main1:5678;
server n8n_main2:5678;
}
server {
listen 80;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}

View file

@ -0,0 +1,15 @@
#!/usr/bin/env zx
import path from 'path';
import { fs } from 'zx';
/**
* Creates the needed directories so the permissions get set correctly.
*/
export function setup({ runDir }) {
const neededDirs = ['n8n-worker1', 'n8n-worker2', 'n8n-main1', 'n8n-main2', 'postgres'];
for (const dir of neededDirs) {
fs.ensureDirSync(path.join(runDir, dir));
}
}

View file

@ -0,0 +1,142 @@
services:
mockapi:
image: wiremock/wiremock:3.9.1
ports:
- '8088:8080'
volumes:
- ${MOCK_API_DATA_PATH}/mappings:/home/wiremock/mappings
redis:
image: redis:6-alpine
ports:
- 6379:6379
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 1s
timeout: 3s
postgres:
image: postgres:16
user: root:root
restart: always
environment:
- POSTGRES_DB=n8n
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ${RUN_DIR}/postgres:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres']
interval: 5s
timeout: 5s
retries: 10
n8n_worker1:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
user: root:root
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n/worker1
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
# Queue mode config
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- QUEUE_HEALTH_CHECK_ACTIVE=true
- N8N_CONCURRENCY_PRODUCTION_LIMIT=10
# DB config
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
command: worker
volumes:
- ${RUN_DIR}/n8n-worker1:/n8n
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'wget --spider -q http://n8n_worker1:5678/healthz || exit 1']
interval: 5s
timeout: 5s
retries: 10
n8n_worker2:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
user: root:root
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n/worker2
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
# Queue mode config
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- QUEUE_HEALTH_CHECK_ACTIVE=true
- N8N_CONCURRENCY_PRODUCTION_LIMIT=10
# DB config
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
command: worker
volumes:
- ${RUN_DIR}/n8n-worker2:/n8n
depends_on:
# We let the worker 1 start first so it can run the DB migrations
n8n_worker1:
condition: service_healthy
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ['CMD-SHELL', 'wget --spider -q http://n8n_worker2:5678/healthz || exit 1']
interval: 5s
timeout: 5s
retries: 10
n8n:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
user: root:root
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n/main
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
# Queue mode config
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
# DB config
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
ports:
- 5678:5678
volumes:
- ${RUN_DIR}/n8n-main:/n8n
depends_on:
n8n_worker1:
condition: service_healthy
n8n_worker2:
condition: service_healthy
postgres:
condition: service_healthy
redis:
condition: service_healthy
mockapi:
condition: service_started
healthcheck:
test: ['CMD-SHELL', 'wget --spider -q http://n8n:5678/healthz || exit 1']
interval: 5s
timeout: 5s
retries: 10
benchmark:
image: ghcr.io/n8n-io/n8n-benchmark:${N8N_BENCHMARK_VERSION:-latest}
depends_on:
n8n:
condition: service_healthy
environment:
- N8N_BASE_URL=http://n8n:5678
- K6_API_TOKEN=${K6_API_TOKEN}
- BENCHMARK_RESULT_WEBHOOK_URL=${BENCHMARK_RESULT_WEBHOOK_URL}
- BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER=${BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER}

View file

@ -0,0 +1,15 @@
#!/usr/bin/env zx
import path from 'path';
import { fs } from 'zx';
/**
* Creates the needed directories so the permissions get set correctly.
*/
export function setup({ runDir }) {
const neededDirs = ['n8n-worker1', 'n8n-worker2', 'n8n-main', 'postgres'];
for (const dir of neededDirs) {
fs.ensureDirSync(path.join(runDir, dir));
}
}

View file

@ -0,0 +1,37 @@
services:
mockapi:
image: wiremock/wiremock:3.9.1
ports:
- '8088:8080'
volumes:
- ${MOCK_API_DATA_PATH}/mappings:/home/wiremock/mappings
n8n:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
user: root:root
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n
ports:
- 5678:5678
volumes:
- ${RUN_DIR}:/n8n
healthcheck:
test: ['CMD-SHELL', 'wget --spider -q http://n8n:5678/healthz || exit 1']
interval: 5s
timeout: 5s
retries: 10
depends_on:
mockapi:
condition: service_started
benchmark:
image: ghcr.io/n8n-io/n8n-benchmark:${N8N_BENCHMARK_VERSION:-latest}
depends_on:
n8n:
condition: service_healthy
environment:
- N8N_BASE_URL=http://n8n:5678
- K6_API_TOKEN=${K6_API_TOKEN}
- BENCHMARK_RESULT_WEBHOOK_URL=${BENCHMARK_RESULT_WEBHOOK_URL}
- BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER=${BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER}

View file

@ -0,0 +1,15 @@
#!/usr/bin/env zx
import path from 'path';
import { fs } from 'zx';
/**
* Creates the needed directories so the permissions get set correctly.
*/
export function setup({ runDir }) {
const neededDirs = ['n8n'];
for (const dir of neededDirs) {
fs.ensureDirSync(path.join(runDir, dir));
}
}

View file

@ -0,0 +1,39 @@
services:
mockapi:
image: wiremock/wiremock:3.9.1
ports:
- '8088:8080'
volumes:
- ${MOCK_API_DATA_PATH}/mappings:/home/wiremock/mappings
n8n:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
user: root:root
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n
- DB_SQLITE_POOL_SIZE=3
- DB_SQLITE_ENABLE_WAL=true
ports:
- 5678:5678
volumes:
- ${RUN_DIR}:/n8n
healthcheck:
test: ['CMD-SHELL', 'wget --spider -q http://n8n:5678/healthz || exit 1']
interval: 5s
timeout: 5s
retries: 10
depends_on:
mockapi:
condition: service_started
benchmark:
image: ghcr.io/n8n-io/n8n-benchmark:${N8N_BENCHMARK_VERSION:-latest}
depends_on:
n8n:
condition: service_healthy
environment:
- N8N_BASE_URL=http://n8n:5678
- K6_API_TOKEN=${K6_API_TOKEN}
- BENCHMARK_RESULT_WEBHOOK_URL=${BENCHMARK_RESULT_WEBHOOK_URL}
- BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER=${BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER}

View file

@ -0,0 +1,15 @@
#!/usr/bin/env zx
import path from 'path';
import { fs } from 'zx';
/**
* Creates the needed directories so the permissions get set correctly.
*/
export function setup({ runDir }) {
const neededDirs = ['n8n'];
for (const dir of neededDirs) {
fs.ensureDirSync(path.join(runDir, dir));
}
}

View file

@ -1,29 +0,0 @@
services:
postgres:
image: postgres:16
restart: always
environment:
- POSTGRES_DB=n8n
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
n8n:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
ports:
- 5678:5678
volumes:
- ${RUN_DIR}:/n8n
depends_on:
- postgres
benchmark:
image: ghcr.io/n8n-io/n8n-benchmark:${N8N_BENCHMARK_VERSION:-latest}
depends_on:
- n8n
environment:
- N8N_BASE_URL=http://n8n:5678
- K6_API_TOKEN=${K6_API_TOKEN}

View file

@ -1,73 +0,0 @@
services:
redis:
image: redis:6-alpine
ports:
- 6379:6379
postgres:
image: postgres:16
restart: always
environment:
- POSTGRES_DB=n8n
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
n8n_worker1:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n/worker1
- N8N_ENCRYPTION_KEY=very-secret-encryption-key
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
command: worker
volumes:
- ${RUN_DIR}:/n8n
depends_on:
- postgres
- redis
n8n_worker2:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n/worker2
- N8N_ENCRYPTION_KEY=very-secret-encryption-key
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
command: worker
volumes:
- ${RUN_DIR}:/n8n
depends_on:
- postgres
- redis
n8n:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n/main
- N8N_ENCRYPTION_KEY=very-secret-encryption-key
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST=redis
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=postgres
- DB_POSTGRESDB_PASSWORD=password
ports:
- 5678:5678
volumes:
- ${RUN_DIR}:/n8n
depends_on:
- postgres
- redis
- n8n_worker1
- n8n_worker2
benchmark:
image: ghcr.io/n8n-io/n8n-benchmark:${N8N_BENCHMARK_VERSION:-latest}
depends_on:
- n8n
environment:
- N8N_BASE_URL=http://n8n:5678
- K6_API_TOKEN=${K6_API_TOKEN}

View file

@ -1,17 +0,0 @@
services:
n8n:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n
ports:
- 5678:5678
volumes:
- ${RUN_DIR}:/n8n
benchmark:
image: ghcr.io/n8n-io/n8n-benchmark:${N8N_BENCHMARK_VERSION:-latest}
depends_on:
- n8n
environment:
- N8N_BASE_URL=http://n8n:5678
- K6_API_TOKEN=${K6_API_TOKEN}

View file

@ -1,19 +0,0 @@
services:
n8n:
image: ghcr.io/n8n-io/n8n:${N8N_VERSION:-latest}
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n
- DB_SQLITE_POOL_SIZE=3
- DB_SQLITE_ENABLE_WAL=true
ports:
- 5678:5678
volumes:
- ${RUN_DIR}:/n8n
benchmark:
image: ghcr.io/n8n-io/n8n-benchmark:${N8N_BENCHMARK_VERSION:-latest}
depends_on:
- n8n
environment:
- N8N_BASE_URL=http://n8n:5678
- K6_API_TOKEN=${K6_API_TOKEN}

View file

@ -0,0 +1,36 @@
#!/usr/bin/env zx
/**
* Provisions the cloud benchmark environment
*
* NOTE: Must be run in the root of the package.
*/
// @ts-check
import { which, minimist } from 'zx';
import { TerraformClient } from './clients/terraform-client.mjs';
const args = minimist(process.argv.slice(3), {
boolean: ['debug'],
});
const isVerbose = !!args.debug;
export async function provision() {
await ensureDependencies();
const terraformClient = new TerraformClient({
isVerbose,
});
await terraformClient.provisionEnvironment();
}
async function ensureDependencies() {
await which('terraform');
}
provision().catch((error) => {
console.error('An error occurred while provisioning cloud env:');
console.error(error);
process.exit(1);
});

View file

@ -0,0 +1,159 @@
#!/usr/bin/env zx
/**
* This script runs the benchmarks for the given n8n setup.
*/
// @ts-check
import path from 'path';
import { $, argv, fs } from 'zx';
import { DockerComposeClient } from './clients/docker-compose-client.mjs';
import { flagsObjectToCliArgs } from './utils/flags.mjs';
const paths = {
n8nSetupsDir: path.join(__dirname, 'n8n-setups'),
mockApiDataPath: path.join(__dirname, 'mock-api'),
};
const N8N_ENCRYPTION_KEY = 'very-secret-encryption-key';
async function main() {
const [n8nSetupToUse] = argv._;
validateN8nSetup(n8nSetupToUse);
const composeFilePath = path.join(paths.n8nSetupsDir, n8nSetupToUse);
const setupScriptPath = path.join(paths.n8nSetupsDir, n8nSetupToUse, 'setup.mjs');
const n8nTag = argv.n8nDockerTag || process.env.N8N_DOCKER_TAG || 'latest';
const benchmarkTag = argv.benchmarkDockerTag || process.env.BENCHMARK_DOCKER_TAG || 'latest';
const k6ApiToken = argv.k6ApiToken || process.env.K6_API_TOKEN || undefined;
const resultWebhookUrl =
argv.resultWebhookUrl || process.env.BENCHMARK_RESULT_WEBHOOK_URL || undefined;
const resultWebhookAuthHeader =
argv.resultWebhookAuthHeader || process.env.BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER || undefined;
const baseRunDir = argv.runDir || process.env.RUN_DIR || '/n8n';
const n8nLicenseCert = argv.n8nLicenseCert || process.env.N8N_LICENSE_CERT || undefined;
const n8nLicenseActivationKey = process.env.N8N_LICENSE_ACTIVATION_KEY || undefined;
const n8nLicenseTenantId = argv.n8nLicenseTenantId || process.env.N8N_LICENSE_TENANT_ID || '1';
const envTag = argv.env || 'local';
const vus = argv.vus;
const duration = argv.duration;
const hasN8nLicense = !!n8nLicenseCert || !!n8nLicenseActivationKey;
if (n8nSetupToUse === 'scaling-multi-main' && !hasN8nLicense) {
console.error(
'n8n license is required to run the multi-main scaling setup. Please provide N8N_LICENSE_CERT or N8N_LICENSE_ACTIVATION_KEY (and N8N_LICENSE_TENANT_ID if needed)',
);
process.exit(1);
}
if (!fs.existsSync(baseRunDir)) {
console.error(
`The run directory "${baseRunDir}" does not exist. Please specify a valid directory using --runDir`,
);
process.exit(1);
}
const runDir = path.join(baseRunDir, n8nSetupToUse);
fs.emptyDirSync(runDir);
const dockerComposeClient = new DockerComposeClient({
$: $({
cwd: composeFilePath,
verbose: true,
env: {
PATH: process.env.PATH,
N8N_VERSION: n8nTag,
N8N_LICENSE_CERT: n8nLicenseCert,
N8N_LICENSE_ACTIVATION_KEY: n8nLicenseActivationKey,
N8N_LICENSE_TENANT_ID: n8nLicenseTenantId,
N8N_ENCRYPTION_KEY,
BENCHMARK_VERSION: benchmarkTag,
K6_API_TOKEN: k6ApiToken,
BENCHMARK_RESULT_WEBHOOK_URL: resultWebhookUrl,
BENCHMARK_RESULT_WEBHOOK_AUTH_HEADER: resultWebhookAuthHeader,
RUN_DIR: runDir,
MOCK_API_DATA_PATH: paths.mockApiDataPath,
},
}),
});
// Run the setup script if it exists
if (fs.existsSync(setupScriptPath)) {
const setupScript = await import(setupScriptPath);
await setupScript.setup({ runDir });
}
try {
await dockerComposeClient.$('up', '-d', '--remove-orphans', 'n8n');
const tags = Object.entries({
Env: envTag,
N8nVersion: n8nTag,
N8nSetup: n8nSetupToUse,
})
.map(([key, value]) => `${key}=${value}`)
.join(',');
const cliArgs = flagsObjectToCliArgs({
scenarioNamePrefix: n8nSetupToUse,
vus,
duration,
tags,
});
await dockerComposeClient.$('run', 'benchmark', 'run', ...cliArgs);
} catch (error) {
console.error('An error occurred while running the benchmarks:');
console.error(error.message);
console.error('');
await printContainerStatus(dockerComposeClient);
console.error('');
await dumpLogs(dockerComposeClient);
} finally {
await dockerComposeClient.$('down');
}
}
async function printContainerStatus(dockerComposeClient) {
console.error('Container statuses:');
await dockerComposeClient.$('ps', '-a');
}
async function dumpLogs(dockerComposeClient) {
console.error('Container logs:');
await dockerComposeClient.$('logs');
}
function printUsage() {
const availableSetups = getAllN8nSetups();
console.log('Usage: zx runForN8nSetup.mjs --runDir /path/for/n8n/data <n8n setup to use>');
console.log(` eg: zx runForN8nSetup.mjs --runDir /path/for/n8n/data ${availableSetups[0]}`);
console.log('');
console.log('Flags:');
console.log(
' --runDir <path> Directory to share with the n8n container for storing data. Default is /n8n',
);
console.log(' --n8nDockerTag <tag> Docker tag for n8n image. Default is latest');
console.log(
' --benchmarkDockerTag <tag> Docker tag for benchmark cli image. Default is latest',
);
console.log(' --k6ApiToken <token> K6 API token to upload the results');
console.log('');
console.log('Available setups:');
console.log(availableSetups.join(', '));
}
/**
* @returns {string[]}
*/
function getAllN8nSetups() {
return fs.readdirSync(paths.n8nSetupsDir);
}
function validateN8nSetup(givenSetup) {
const availableSetups = getAllN8nSetups();
if (!availableSetups.includes(givenSetup)) {
printUsage();
process.exit(1);
}
}
main();

View file

@ -0,0 +1,160 @@
#!/usr/bin/env zx
/**
* Script to run benchmarks on the cloud benchmark environment.
* This script will:
* 1. Provision a benchmark environment using Terraform.
* 2. Run the benchmarks on the VM.
* 3. Destroy the cloud environment.
*
* NOTE: Must be run in the root of the package.
*/
// @ts-check
import { sleep, which, $, tmpdir } from 'zx';
import path from 'path';
import { SshClient } from './clients/ssh-client.mjs';
import { TerraformClient } from './clients/terraform-client.mjs';
import { flagsObjectToCliArgs } from './utils/flags.mjs';
/**
* @typedef {Object} BenchmarkEnv
* @property {string} vmName
* @property {string} ip
* @property {string} sshUsername
* @property {string} sshPrivateKeyPath
*/
/**
* @typedef {Object} Config
* @property {boolean} isVerbose
* @property {string[]} n8nSetupsToUse
* @property {string} n8nTag
* @property {string} benchmarkTag
* @property {string} [k6ApiToken]
* @property {string} [resultWebhookUrl]
* @property {string} [resultWebhookAuthHeader]
* @property {string} [n8nLicenseCert]
* @property {string} [vus]
* @property {string} [duration]
*
* @param {Config} config
*/
export async function runInCloud(config) {
await ensureDependencies();
const terraformClient = new TerraformClient({
isVerbose: config.isVerbose,
});
const benchmarkEnv = await terraformClient.getTerraformOutputs();
await runBenchmarksOnVm(config, benchmarkEnv);
}
async function ensureDependencies() {
await which('terraform');
await which('az');
}
/**
* @param {Config} config
* @param {BenchmarkEnv} benchmarkEnv
*/
async function runBenchmarksOnVm(config, benchmarkEnv) {
console.log(`Setting up the environment...`);
const sshClient = new SshClient({
ip: benchmarkEnv.ip,
username: benchmarkEnv.sshUsername,
privateKeyPath: benchmarkEnv.sshPrivateKeyPath,
verbose: config.isVerbose,
});
await ensureVmIsReachable(sshClient);
const scriptsDir = await transferScriptsToVm(sshClient, config);
// Bootstrap the environment with dependencies
console.log('Running bootstrap script...');
const bootstrapScriptPath = path.join(scriptsDir, 'bootstrap.sh');
await sshClient.ssh(`chmod a+x ${bootstrapScriptPath} && ${bootstrapScriptPath}`);
// Benchmarking the VM
const vmBenchmarkScriptPath = path.join(scriptsDir, 'vm-benchmark.sh');
await sshClient.ssh(`chmod a+x ${vmBenchmarkScriptPath} && ${vmBenchmarkScriptPath}`, {
verbose: true,
});
// Give some time for the VM to be ready
await sleep(1000);
for (const n8nSetup of config.n8nSetupsToUse) {
await runBenchmarkForN8nSetup({
config,
sshClient,
scriptsDir,
n8nSetup,
});
}
}
/**
* @param {{ config: Config; sshClient: any; scriptsDir: string; n8nSetup: string; }} opts
*/
async function runBenchmarkForN8nSetup({ config, sshClient, scriptsDir, n8nSetup }) {
console.log(`Running benchmarks for ${n8nSetup}...`);
const runScriptPath = path.join(scriptsDir, 'run-for-n8n-setup.mjs');
const cliArgs = flagsObjectToCliArgs({
n8nDockerTag: config.n8nTag,
benchmarkDockerTag: config.benchmarkTag,
k6ApiToken: config.k6ApiToken,
resultWebhookUrl: config.resultWebhookUrl,
resultWebhookAuthHeader: config.resultWebhookAuthHeader,
n8nLicenseCert: config.n8nLicenseCert,
vus: config.vus,
duration: config.duration,
env: 'cloud',
});
const flagsString = cliArgs.join(' ');
await sshClient.ssh(`npx zx ${runScriptPath} ${flagsString} ${n8nSetup}`, {
// Test run should always log its output
verbose: true,
});
}
async function ensureVmIsReachable(sshClient) {
try {
await sshClient.ssh('echo "VM is reachable"');
} catch (error) {
console.error(`VM is not reachable: ${error.message}`);
console.error(
`Did you provision the cloud environment first with 'pnpm provision-cloud-env'? You can also run the benchmarks locally with 'pnpm run benchmark-locally'.`,
);
process.exit(1);
}
}
/**
* @returns Path where the scripts are located on the VM
*/
async function transferScriptsToVm(sshClient, config) {
const cwd = process.cwd();
const scriptsDir = path.resolve(cwd, './scripts');
const tarFilename = 'scripts.tar.gz';
const scriptsTarPath = path.join(tmpdir('n8n-benchmark'), tarFilename);
const $$ = $({ verbose: config.isVerbose });
// Compress the scripts folder
await $$`tar -czf ${scriptsTarPath} ${scriptsDir} -C ${cwd} ./scripts`;
// Transfer the scripts to the VM
await sshClient.scp(scriptsTarPath, `~/${tarFilename}`);
// Extract the scripts on the VM
await sshClient.ssh(`tar -xzf ~/${tarFilename}`);
return '~/scripts';
}

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