Merge remote-tracking branch 'origin/master' into ADO-3851

This commit is contained in:
Charlie Kolb 2025-08-04 10:04:50 +02:00
commit fa33743e1f
No known key found for this signature in database
1465 changed files with 174180 additions and 166771 deletions

View File

@ -15,5 +15,5 @@ packages/**/*.test.*
docker/compose
docker/**/Dockerfile
.vscode
cypress
test-workflows
packages/testing
cypress

View File

@ -4,7 +4,13 @@ body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
> ⚠️ This form is for reporting bugs only.
> ❌ Please do not use this form for general support, feature requests, or questions.
> 💬 For help and general inquiries, visit our [community support forum](https://community.n8n.io).
> ☁️ If you're experiencing issues with cloud instances not starting or license-related problems, contact [n8n support directly](mailto:help@n8n.io).
---
Thank you for helping us improve n8n!
To ensure we can address your report efficiently, please fill out all sections in English and provide as much detail as possible.
- type: textarea
id: description
attributes:
@ -32,6 +38,13 @@ body:
description: A clear and concise description of what you expected to happen
validations:
required: true
- type: textarea
id: debug-info
attributes:
label: Debug Info
description: This can be found under Help > About n8n > Copy debug information
validations:
required: true
- type: markdown
attributes:
value: '## Environment'
@ -80,3 +93,13 @@ body:
default: 0
validations:
required: true
- type: dropdown
id: hosting
attributes:
label: Hosting
options:
- n8n cloud
- self hosted
default: 0
validations:
required: true

View File

@ -16,20 +16,10 @@ jobs:
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- name: Setup Node.js
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
with:
node-version: 22.x
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.33
corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build relevant packages
run: pnpm build:nodes
build-command: turbo build --filter=*nodes*
- run: npm install --prefix=.github/scripts --no-package-lock

View File

@ -14,23 +14,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check out branch
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.33
corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Validate PR title
id: validate_pr_title
uses: n8n-io/validate-n8n-pull-request-title@c97ff722ac14ee0bda73766473bba764445db805 # v2.2.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -6,64 +6,24 @@ on:
- master
jobs:
install-and-build:
runs-on: blacksmith-2vcpu-ubuntu-2204
env:
NODE_OPTIONS: '--max-old-space-size=4096'
timeout-minutes: 10
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: useblacksmith/setup-node@65c6ca86fdeb0ab3d85e78f57e4f6a7e4780b391 # v5
with:
node-version: 22.x
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.33
corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup build cache
uses: useblacksmith/caching-for-turbo@bafb57e7ebdbf1185762286ec94d24648cd3938a # v1
- name: Build
run: pnpm build
- name: Cache build artifacts
uses: useblacksmith/cache/save@c5fe29eb0efdf1cf4186b9f7fcbbcbc0cf025662 # v5
with:
path: ./packages/**/dist
key: ${{ github.sha }}-base:build
unit-test:
name: Unit tests
uses: ./.github/workflows/units-tests-reusable.yml
needs: install-and-build
strategy:
matrix:
node-version: [20.x, 22.x, 24.x]
node-version: [20.x, 22.x, 24.3.x]
with:
ref: ${{ inputs.branch }}
nodeVersion: ${{ matrix.node-version }}
cacheKey: ${{ github.sha }}-base:build
collectCoverage: ${{ matrix.node-version == '22.x' }}
ignoreTurboCache: ${{ matrix.node-version == '22.x' }}
skipFrontendTests: ${{ matrix.node-version != '22.x' }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
lint:
name: Lint
uses: ./.github/workflows/linting-reusable.yml
needs: install-and-build
with:
ref: ${{ inputs.branch }}
cacheKey: ${{ github.sha }}-base:build
notify-on-failure:
name: Notify Slack on failure

View File

@ -21,41 +21,24 @@ concurrency:
group: db-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: false
env:
NODE_OPTIONS: '--max-old-space-size=3072'
jobs:
build:
name: Install & Build
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2204
if: github.event_name != 'pull_request_review' || startsWith(github.event.pull_request.base.ref, 'release/')
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.33
corepack enable
- run: pnpm install --frozen-lockfile
- name: Setup build cache
uses: rharkor/caching-for-turbo@439abec0d28d21b192fa8817b744ffdf1ee5ac0d # v1.5
- name: Build
run: pnpm build
- name: Cache build artifacts
uses: actions/cache/save@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
with:
path: ./packages/**/dist
key: ${{ github.sha }}:db-tests
- name: Setup and Build
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
sqlite-pooled:
name: SQLite Pooled
runs-on: ubuntu-latest
needs: build
runs-on: blacksmith-2vcpu-ubuntu-2204
timeout-minutes: 20
env:
DB_TYPE: sqlite
@ -63,25 +46,8 @@ jobs:
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.33
corepack enable
- run: pnpm install --frozen-lockfile
- name: Setup build cache
uses: rharkor/caching-for-turbo@439abec0d28d21b192fa8817b744ffdf1ee5ac0d # v1.5
- name: Restore cached build artifacts
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
with:
path: ./packages/**/dist
key: ${{ github.sha }}:db-tests
- name: Setup and Build
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
- name: Test SQLite Pooled
working-directory: packages/cli
@ -89,33 +55,16 @@ jobs:
mariadb:
name: MariaDB
runs-on: ubuntu-latest
needs: build
runs-on: blacksmith-2vcpu-ubuntu-2204
timeout-minutes: 20
env:
DB_MYSQLDB_PASSWORD: password
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.33
corepack enable
- run: pnpm install --frozen-lockfile
- name: Setup build cache
uses: rharkor/caching-for-turbo@439abec0d28d21b192fa8817b744ffdf1ee5ac0d # v1.5
- name: Restore cached build artifacts
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
with:
path: ./packages/**/dist
key: ${{ github.sha }}:db-tests
- name: Setup and Build
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
- name: Start MariaDB
uses: isbang/compose-action@802a148945af6399a338c7906c267331b39a71af # v2.0.0
@ -130,8 +79,8 @@ jobs:
mysql:
name: MySQL (${{ matrix.service-name }})
runs-on: ubuntu-latest
needs: build
runs-on: blacksmith-2vcpu-ubuntu-2204
timeout-minutes: 20
strategy:
matrix:
@ -141,25 +90,8 @@ jobs:
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.33
corepack enable
- run: pnpm install --frozen-lockfile
- name: Setup build cache
uses: rharkor/caching-for-turbo@439abec0d28d21b192fa8817b744ffdf1ee5ac0d # v1.5
- name: Restore cached build artifacts
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
with:
path: ./packages/**/dist
key: ${{ github.sha }}:db-tests
- name: Setup and Build
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
- name: Start MySQL
uses: isbang/compose-action@802a148945af6399a338c7906c267331b39a71af # v2.0.0
@ -174,8 +106,8 @@ jobs:
postgres:
name: Postgres
runs-on: ubuntu-latest
needs: build
runs-on: blacksmith-2vcpu-ubuntu-2204
timeout-minutes: 20
env:
DB_POSTGRESDB_PASSWORD: password
@ -183,25 +115,8 @@ jobs:
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.33
corepack enable
- run: pnpm install --frozen-lockfile
- name: Setup build cache
uses: rharkor/caching-for-turbo@439abec0d28d21b192fa8817b744ffdf1ee5ac0d # v1.5
- name: Restore cached build artifacts
uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
with:
path: ./packages/**/dist
key: ${{ github.sha }}:db-tests
- name: Setup and Build
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
- name: Start Postgres
uses: isbang/compose-action@802a148945af6399a338c7906c267331b39a71af # v2.0.0

View File

@ -11,7 +11,7 @@ jobs:
name: Install & Build
runs-on: blacksmith-2vcpu-ubuntu-2204
env:
NODE_OPTIONS: '--max-old-space-size=4096'
NODE_OPTIONS: '--max-old-space-size=3072'
outputs:
frontend_changed: ${{ steps.paths-filter.outputs.frontend == 'true' }}
steps:
@ -31,14 +31,10 @@ jobs:
- packages/@n8n/codemirror-lang/**
- .bundlemonrc.json
- .github/workflows/ci-pull-requests.yml
- name: Setup and Build
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
- name: Setup Environment and Build Project
uses: ./.github/actions/setup-and-build
with:
node-version: 22.x
enable-caching: true
- name: Run formatcheck
- name: Run format check
run: pnpm format:check
- name: Run typecheck
@ -89,7 +85,6 @@ jobs:
needs: install-and-build
with:
ref: refs/pull/${{ github.event.pull_request.number }}/merge
cacheKey: ${{ github.sha }}-base:build
collectCoverage: true
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
@ -100,4 +95,3 @@ jobs:
needs: install-and-build
with:
ref: refs/pull/${{ github.event.pull_request.number }}/merge
cacheKey: ${{ github.sha }}-base:build

48
.github/workflows/claude.yml vendored Normal file
View File

@ -0,0 +1,48 @@
name: Claude PR Assistant
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude-code-action:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && contains(github.event.issue.body, '@claude'))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude PR Action
uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Or use OAuth token instead:
# claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
timeout_minutes: '60'
# mode: tag # Default: responds to @claude mentions
# Optional: Restrict network access to specific domains only
# experimental_allowed_domains: |
# .anthropic.com
# .github.com
# api.github.com
# .githubusercontent.com
# bun.sh
# registry.npmjs.org
# .blob.core.windows.net

View File

@ -7,7 +7,7 @@
name: 'Docker: Build and Push'
env:
NODE_OPTIONS: '--max-old-space-size=8192'
NODE_OPTIONS: '--max-old-space-size=7168'
on:
schedule:
@ -166,26 +166,10 @@ jobs:
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup and Build
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
with:
run_install: false
- name: Setup Node.js
uses: useblacksmith/setup-node@65c6ca86fdeb0ab3d85e78f57e4f6a7e4780b391 # v5.0.4
with:
node-version: 22.x
- name: Install dependencies
run: pnpm install --frozen-lockfile
shell: bash
- name: Configure Turborepo Cache
uses: useblacksmith/caching-for-turbo@bafb57e7ebdbf1185762286ec94d24648cd3938a # v1
- name: Build n8n for Docker
run: pnpm build:n8n
shell: bash
build-command: pnpm build:n8n
- name: Determine Docker tags
id: determine-tags

View File

@ -1,70 +0,0 @@
name: Debug Flaky E2E Test
on:
workflow_dispatch:
inputs:
test_name:
description: 'The name of the test to filter.'
required: true
type: string
burn_count:
description: 'Number of times to run the test.'
required: false
type: number
default: 50
branch:
description: 'Optional: GitHub branch, tag, or SHA to test. Defaults to the branch selected in UI.'
required: false
type: string
jobs:
debug-test:
runs-on: blacksmith-4vcpu-ubuntu-2204
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.inputs.branch }}
- name: Setup PNPM
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 22.x
cache: 'pnpm'
- name: Cache build artifacts
id: cache-build-artifacts
uses: useblacksmith/cache@c5fe29eb0efdf1cf4186b9f7fcbbcbc0cf025662 # v5.0.2
with:
path: |
/home/runner/.cache/Cypress
./packages/**/dist
key: ${{ github.ref }}-${{ github.sha }}-debug-build
restore-keys: |
${{ github.ref }}-debug-build-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build application
if: steps.cache-build-artifacts.outputs.cache-hit != 'true'
run: pnpm build
- name: Cypress install
if: steps.cache-build-artifacts.outputs.cache-hit != 'true'
working-directory: cypress
run: pnpm cypress:install
- name: Run Flaky Debug Command
env:
TEST_NAME: ${{ github.event.inputs.test_name }}
BURN_COUNT: ${{ github.event.inputs.burn_count }}
NODE_OPTIONS: --dns-result-order=ipv4first
E2E_TESTS: true
SHELL: /bin/sh
run: pnpm run debug:flaky:e2e "${{ env.TEST_NAME }}" "${{ env.BURN_COUNT }}"

View File

@ -45,7 +45,7 @@ on:
required: true
env:
NODE_OPTIONS: --max-old-space-size=4096
NODE_OPTIONS: --max-old-space-size=3072
jobs:
testing:

View File

@ -13,11 +13,9 @@ on:
required: false
type: string
default: 22.x
cacheKey:
description: Cache key for modules and build artifacts.
required: false
type: string
default: ''
env:
NODE_OPTIONS: --max-old-space-size=7168
jobs:
lint:
@ -28,21 +26,8 @@ jobs:
with:
ref: ${{ inputs.ref }}
- name: Setup Environment
uses: n8n-io/n8n/.github/actions/setup-and-build@7e870b8f7f5a39bb8bf82d1f42b6d44febc0082c # v1.100.1
- name: Build and Test
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
with:
build-command: pnpm lint
node-version: ${{ inputs.nodeVersion }}
enable-caching: true
skip-build: ${{ inputs.cacheKey != '' }}
- name: Lint Backend
run: pnpm lint:backend
- name: Lint Nodes
run: pnpm lint:nodes
- name: Lint Frontend
run: pnpm lint:frontend
- name: Lint Testing
run: pnpm lint:testing

View File

@ -25,7 +25,7 @@ on:
env:
PLAYWRIGHT_BROWSERS_PATH: packages/testing/playwright/ms-playwright-cache
NODE_OPTIONS: --max-old-space-size=4096
NODE_OPTIONS: --max-old-space-size=3072
# Disable Ryuk to avoid issues with Docker since it needs privileged access, containers are cleaned on teardown anyway
TESTCONTAINERS_RYUK_DISABLED: true

View File

@ -1,4 +1,4 @@
name: Callable Test Workflows
name: Test Workflows - Reusable
on:
workflow_call:
@ -7,208 +7,47 @@ on:
description: 'The Git ref (branch, tag, or SHA) to checkout and test.'
required: true
type: string
send_webhook_report:
description: 'Set to true to send test results to the webhook.'
required: false
type: boolean
default: false
pr_number:
description: 'The PR number, if applicable (for context in webhook).'
compare_schemas:
description: 'Set to "true" to enable schema comparison during tests.'
required: false
default: 'true'
type: string
default: ''
secrets:
ENCRYPTION_KEY:
description: 'Encryption key for n8n operations.'
required: true
CI_SENTRY_DSN:
description: 'Sentry DSN for CI test runs.'
required: false
WORKFLOW_TESTS_RESULT_DESTINATION:
description: 'Webhook URL to send test results to (if enabled).'
required: false
CURRENTS_RECORD_KEY:
description: 'Currents record key for uploading test results.'
required: true
env:
NODE_OPTIONS: --max-old-space-size=3072
jobs:
build_and_test:
name: Install, Build, and Test Workflows
run_workflow_tests:
name: Run Workflow Tests with Snapshots
runs-on: blacksmith-2vcpu-ubuntu-2204
timeout-minutes: 10
env:
N8N_ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
steps:
- name: Checkout repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.git_ref }}
- name: Setup Environment and Build Project
uses: n8n-io/n8n/.github/actions/setup-and-build@7e870b8f7f5a39bb8bf82d1f42b6d44febc0082c # v1.100.1
with:
node-version: '22.x'
cache-suffix: 'workflow-test'
- name: Set up Environment
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
- name: Install OS dependencies
run: |
sudo apt update -y
echo 'tzdata tzdata/Areas select Europe' | sudo debconf-set-selections
echo 'tzdata tzdata/Zones/Europe select Paris' | sudo debconf-set-selections
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends graphicsmagick
sudo apt-get clean
sudo rm -rf /var/lib/apt/lists/*
- name: Import credentials
run: ./packages/cli/bin/n8n import:credentials --input=test-workflows/credentials.json
- name: Import workflows
run: ./packages/cli/bin/n8n import:workflow --separate --input=test-workflows/workflows
- name: Copy static assets
run: |
mkdir -p /tmp/testData/pdfs
cp assets/n8n-logo.png /tmp/n8n-logo.png
cp assets/n8n-screenshot.png /tmp/n8n-screenshot.png
cp test-workflows/testData/pdfs/*.pdf /tmp/testData/pdfs/
- name: Run tests
id: tests
run: ./packages/cli/bin/n8n executeBatch --shallow --skipList=test-workflows/skipList.json --githubWorkflow --shortOutput --output=test-results.json --concurrency=16 --compare=test-workflows/snapshots
continue-on-error: true
- name: Set up Workflow Tests
run: pnpm --filter=n8n-playwright test:workflows:setup
env:
SKIP_STATISTICS_EVENTS: 'true'
DB_SQLITE_POOL_SIZE: '4'
N8N_SENTRY_DSN: ${{ secrets.CI_SENTRY_DSN }}
N8N_ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
- name: Report test outcome
if: always()
run: |
echo "Test step outcome was: ${{ steps.tests.outcome }}"
if [[ "${{ steps.tests.outcome }}" == "failure" ]]; then
echo "Workflow tests failed but the workflow will continue."
elif [[ "${{ steps.tests.outcome }}" == "success" ]]; then
echo "Workflow tests passed."
else
echo "Workflow tests outcome: ${{ steps.tests.outcome }}"
fi
- name: Prepare and Send Test Results to Webhook
if: inputs.send_webhook_report == true
shell: bash
- name: Run Workflow Tests
run: pnpm --filter=n8n-playwright test:workflows --workers 4
env:
WEBHOOK_URL: ${{ secrets.WORKFLOW_TESTS_RESULT_DESTINATION }}
TEST_RESULTS_FILE: ./test-results.json
GH_REPOSITORY: ${{ github.repository }}
GH_RUN_ID: ${{ github.run_id }}
GH_RUN_ATTEMPT: ${{ github.run_attempt }}
GH_REF_TESTED: ${{ inputs.git_ref }}
GH_EVENT_NAME: ${{ github.event_name }}
GH_PR_NUMBER_INPUT: ${{ inputs.pr_number }}
GH_WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
GH_ACTOR: ${{ github.actor }}
run: |
echo "Attempting to send test results to webhook..."
echo "Test results file expected at: $TEST_RESULTS_FILE"
CURRENTS_RECORD_KEY: ${{ secrets.CURRENTS_RECORD_KEY }}
if [ ! -f "$TEST_RESULTS_FILE" ]; then
echo "::warning::Test results file ($TEST_RESULTS_FILE) not found. Skipping webhook."
exit 0
fi
if ! command -v jq &> /dev/null; then
echo "jq not found. Installing jq..."
sudo apt-get update -qq && sudo apt-get install -y -qq jq
if ! command -v jq &> /dev/null; then
echo "::error::Failed to install jq. Cannot process JSON."
exit 1
fi
fi
pr_number_to_send="$GH_PR_NUMBER_INPUT"
echo "Preparing JSON payload..."
if [ ! -s "$TEST_RESULTS_FILE" ]; then
echo "::warning::Test results file ($TEST_RESULTS_FILE) is empty. Sending only GitHub context."
enriched_payload=$(jq -n \
--arg repository "$GH_REPOSITORY" \
--arg run_id "$GH_RUN_ID" \
--arg run_attempt "$GH_RUN_ATTEMPT" \
--arg ref_tested "$GH_REF_TESTED" \
--arg event_name "$GH_EVENT_NAME" \
--arg pr_num "$pr_number_to_send" \
--arg workflow_run_url "$GH_WORKFLOW_RUN_URL" \
--arg actor "$GH_ACTOR" \
'{
githubWorkflowContext: {
repository: $repository,
runId: $run_id,
runAttempt: $run_attempt,
gitRefTested: $ref_tested,
triggeringEventName: $event_name,
prNumber: (if $pr_num == "" then null else $pr_num | tonumber? // $pr_num end),
workflowRunUrl: $workflow_run_url,
triggeredBy: $actor
}
}')
else
enriched_payload=$(jq \
--arg repository "$GH_REPOSITORY" \
--arg run_id "$GH_RUN_ID" \
--arg run_attempt "$GH_RUN_ATTEMPT" \
--arg ref_tested "$GH_REF_TESTED" \
--arg event_name "$GH_EVENT_NAME" \
--arg pr_num "$pr_number_to_send" \
--arg workflow_run_url "$GH_WORKFLOW_RUN_URL" \
--arg actor "$GH_ACTOR" \
'. + {
githubWorkflowContext: {
repository: $repository,
runId: $run_id,
runAttempt: $run_attempt,
gitRefTested: $ref_tested,
triggeringEventName: $event_name,
prNumber: (if $pr_num == "" then null else $pr_num | tonumber? // $pr_num end),
workflowRunUrl: $workflow_run_url,
triggeredBy: $actor
}
}' "$TEST_RESULTS_FILE")
fi
jq_exit_code=$?
if [ $jq_exit_code -ne 0 ] || [ -z "$enriched_payload" ]; then
echo "::error::Failed to process JSON with jq (exit code: $jq_exit_code). Input file: $TEST_RESULTS_FILE"
if [ -s "$TEST_RESULTS_FILE" ]; then
echo "Contents of $TEST_RESULTS_FILE that may have caused an error:"
head -c 1000 "$TEST_RESULTS_FILE" # Print first 1000 chars
echo "" # Newline after head
elif [ -f "$TEST_RESULTS_FILE" ]; then
echo "$TEST_RESULTS_FILE exists but is empty."
fi
exit 1
fi
echo "Enriched payload to send (first 500 chars):"
echo "$enriched_payload" | head -c 500
echo ""
echo "Sending data to webhook: $WEBHOOK_URL"
http_response_code=$(curl -s -w "%{http_code}" \
-X POST \
-H "Content-Type: application/json" \
-H "X-GitHub-Event: $GH_EVENT_NAME" \
-H "X-GitHub-Run-Id: $GH_RUN_ID" \
--data "$enriched_payload" \
"$WEBHOOK_URL" \
-o curl_response_body.txt 2>curl_stderr.txt)
curl_stderr_content=$(cat curl_stderr.txt)
if [ -n "$curl_stderr_content" ]; then
echo "::warning::curl stderr: $curl_stderr_content"
fi
echo "Webhook response code: $http_response_code"
echo "Webhook response body:"
cat curl_response_body.txt
if [[ "$http_response_code" -ge 200 && "$http_response_code" -lt 300 ]]; then
echo "Successfully sent data to webhook."
else
echo "::error::Webhook call failed with status code $http_response_code."
fi
- name: Run Workflow Schema Tests
if: ${{ inputs.compare_schemas == 'true' }}
run: pnpm --filter=n8n-playwright test:workflows:schema
env:
CURRENTS_RECORD_KEY: ${{ secrets.CURRENTS_RECORD_KEY }}

View File

@ -11,16 +11,10 @@ on:
type: string
default: 'master'
permissions:
contents: read
jobs:
run_workflow_tests:
name: Run Workflow Tests
uses: ./.github/workflows/test-workflows-callable.yml
with:
git_ref: ${{ github.event_name == 'schedule' && 'master' || github.event.inputs.git_ref_to_test }}
send_webhook_report: false
pr_number: ''
secrets: inherit
secrets: inherit

View File

@ -22,6 +22,4 @@ jobs:
uses: ./.github/workflows/test-workflows-callable.yml
with:
git_ref: ${{ github.event.pull_request.head.sha }}
send_webhook_report: true
pr_number: ${{ github.event.pull_request.number }}
secrets: inherit
secrets: inherit

View File

@ -107,6 +107,4 @@ jobs:
uses: ./.github/workflows/test-workflows-callable.yml
with:
git_ref: ${{ needs.handle_comment_command.outputs.git_ref }}
send_webhook_report: true
pr_number: ${{ needs.handle_comment_command.outputs.pr_number }}
secrets: inherit

View File

@ -13,63 +13,36 @@ on:
required: false
type: string
default: 22.x
cacheKey:
description: Cache key for modules and build artifacts.
required: false
default: ''
type: string
collectCoverage:
required: false
default: false
type: boolean
ignoreTurboCache:
required: false
default: false
type: boolean
skipFrontendTests:
required: false
default: false
type: boolean
secrets:
CODECOV_TOKEN:
description: 'Codecov upload token.'
required: false
env:
NODE_OPTIONS: --max-old-space-size=7168
jobs:
unit-test:
name: Unit tests
runs-on: blacksmith-4vcpu-ubuntu-2204
env:
TURBO_FORCE: ${{ inputs.ignoreTurboCache }}
COVERAGE_ENABLED: ${{ inputs.collectCoverage }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.ref }}
- name: Setup Environment and Build Project
uses: n8n-io/n8n/.github/actions/setup-and-build@7e870b8f7f5a39bb8bf82d1f42b6d44febc0082c # v1.100.1
- name: Build
uses: n8n-io/n8n/.github/actions/setup-nodejs-blacksmith@f5fbbbe0a28a886451c886cac6b49192a39b0eea # v1.104.1
with:
node-version: ${{ inputs.nodeVersion }}
skip-build: ${{ inputs.cacheKey != '' }}
- name: Restore cached build artifacts only
if: ${{ inputs.cacheKey != '' }}
uses: useblacksmith/cache/restore@c5fe29eb0efdf1cf4186b9f7fcbbcbc0cf025662 # v5.0.2
with:
path: ./packages/**/dist
key: ${{ inputs.cacheKey }}
fail-on-cache-miss: true
- name: Test Backend
run: pnpm test:backend
- name: Test Nodes
run: pnpm test:nodes
- name: Test Frontend
if: ${{ !inputs.skipFrontendTests }}
run: pnpm test:frontend
- name: Test
run: pnpm test:ci
- name: Upload test results to Codecov
if: ${{ !cancelled() }}

3
.gitignore vendored
View File

@ -33,5 +33,4 @@ test-results/
compiled_app_output
trivy_report*
compiled
jest.config.js
packages/cli/src/modules/my-feature
packages/cli/src/modules/my-feature

View File

@ -1,3 +1,63 @@
# [1.105.0](https://github.com/n8n-io/n8n/compare/n8n@1.104.0...n8n@1.105.0) (2025-07-28)
### Bug Fixes
* **core:** Add headers to telemetry cross origin request ([#17631](https://github.com/n8n-io/n8n/issues/17631)) ([251e892](https://github.com/n8n-io/n8n/commit/251e892a09b82b2f1f980d3984e3aef84ed1732e))
* **core:** Decouple removing and closing destination from actually deleting it ([#17614](https://github.com/n8n-io/n8n/issues/17614)) ([b09f737](https://github.com/n8n-io/n8n/commit/b09f73701d8b6ce0e3bc4ef28d0f5d9fc7fb5df1))
* **core:** Fetching schema files in Docker ([#17623](https://github.com/n8n-io/n8n/issues/17623)) ([5a3b0a2](https://github.com/n8n-io/n8n/commit/5a3b0a24811eea5ebd9d80c26a54fea74665569f))
* **core:** Fix getLiveExecutionRowsOnPostgres when there are multiple n8n schemas ([#17635](https://github.com/n8n-io/n8n/issues/17635)) ([9e3bfff](https://github.com/n8n-io/n8n/commit/9e3bfff68d35a2eb21ba43452fc01ee24601c122))
* **core:** Ignore pairedItem when checking for incorrect output data from a node ([#17340](https://github.com/n8n-io/n8n/issues/17340)) ([2708fe8](https://github.com/n8n-io/n8n/commit/2708fe81a5323687c59c3d483d6bf3c67464f657))
* **core:** Make the module loading for local dev more generic ([#17547](https://github.com/n8n-io/n8n/issues/17547)) ([01b95a9](https://github.com/n8n-io/n8n/commit/01b95a9ee5dd4985e4770ef625ced91158f849da))
* **core:** Optimize connection type lookups ([#17585](https://github.com/n8n-io/n8n/issues/17585)) ([70eab1b](https://github.com/n8n-io/n8n/commit/70eab1b2a02d70a46a56e8c993ccc694e38ac2d5))
* **core:** Remove non-included nodes regardless of the package when using NODES_INCLUDE ([#17517](https://github.com/n8n-io/n8n/issues/17517)) ([1641d39](https://github.com/n8n-io/n8n/commit/1641d3964b72539354a939400af91e2692368058))
* Do not throw on tool errors, instead return error message ([#17558](https://github.com/n8n-io/n8n/issues/17558)) ([f11ec53](https://github.com/n8n-io/n8n/commit/f11ec538dca2938e57302a1bedd5dd7d1e7a9488))
* **editor:** Add background same as that of the parent ([#17625](https://github.com/n8n-io/n8n/issues/17625)) ([8660057](https://github.com/n8n-io/n8n/commit/8660057350e21b604b3fb3f627ccd32659058e87))
* **editor:** Case-sensitive credential search in `NodeCredentials` component ([#17564](https://github.com/n8n-io/n8n/issues/17564)) ([3ce9a99](https://github.com/n8n-io/n8n/commit/3ce9a998ae454929207dd9add4a67b68dba13bc8))
* **editor:** Do not show new NDV for sticky notes ([#17537](https://github.com/n8n-io/n8n/issues/17537)) ([4de3759](https://github.com/n8n-io/n8n/commit/4de3759a59cade3f82c57a1eeba1c6b4a16a3eaf))
* **editor:** Fix canvas layouting when tab is not active ([#17638](https://github.com/n8n-io/n8n/issues/17638)) ([2df76e0](https://github.com/n8n-io/n8n/commit/2df76e020ef3a962fc991f2d108a8181914a2dd1))
* **editor:** Fix error when there is no path back to referenced node ([#16059](https://github.com/n8n-io/n8n/issues/16059)) ([d6ac924](https://github.com/n8n-io/n8n/commit/d6ac924b3b7d2205cbcc0e5edc7ad407f4fe2a19))
* **editor:** Fix layout of binary data preview in the log view ([#17584](https://github.com/n8n-io/n8n/issues/17584)) ([456c4e8](https://github.com/n8n-io/n8n/commit/456c4e8167ed95e5f096daaae9cc46cad90a0981))
* **editor:** Fix trimPayloadToSize mutating original objects in AI assistant ([#17498](https://github.com/n8n-io/n8n/issues/17498)) ([1010043](https://github.com/n8n-io/n8n/commit/101004390bf5cdf5f67675dcfccb551f71ea4b70))
* **editor:** Hide `What's New` notification in executions demo view ([#17742](https://github.com/n8n-io/n8n/issues/17742)) ([cebb1f6](https://github.com/n8n-io/n8n/commit/cebb1f65669638a6716dbbd2eb9873ae8dbfe108))
* **editor:** Improve filter change handling with debounced updates for date fields ([#17618](https://github.com/n8n-io/n8n/issues/17618)) ([ae08917](https://github.com/n8n-io/n8n/commit/ae089173a71b3417ca07ae4bf49d4b0b3d31bf09))
* **editor:** Make inline text edit component reactive to prop changes ([#17557](https://github.com/n8n-io/n8n/issues/17557)) ([9c793a4](https://github.com/n8n-io/n8n/commit/9c793a45c562631ec331f65ca871334f5a8a8e2f))
* **editor:** Make sure HTML editor field is not editable when workflow is in read only mode ([#17561](https://github.com/n8n-io/n8n/issues/17561)) ([18c02df](https://github.com/n8n-io/n8n/commit/18c02dfa2b5cf76663b4678046a8bcb313fba1f4))
* **editor:** Persist SSO protocol setting properly in the UI ([#17572](https://github.com/n8n-io/n8n/issues/17572)) ([4b2be26](https://github.com/n8n-io/n8n/commit/4b2be263790a53bf46b99f3301ddec6a771b2daf))
* **editor:** Prevent default action on Enter key in commit and push dialog ([#17578](https://github.com/n8n-io/n8n/issues/17578)) ([e317c92](https://github.com/n8n-io/n8n/commit/e317c929161733a03ff61c07ae6f3ae12cf22ef2))
* **editor:** Prevent unnecessary updates on model value change in InlineTextEdit component ([#17553](https://github.com/n8n-io/n8n/issues/17553)) ([832b7fd](https://github.com/n8n-io/n8n/commit/832b7fda3b59cc518624128ca98d26983cb444fd))
* **editor:** Remove inline script and style from index.html ([#17531](https://github.com/n8n-io/n8n/issues/17531)) ([0db24ce](https://github.com/n8n-io/n8n/commit/0db24ce71b671f6311fc47ac9553466d34c46ba8))
* **editor:** Render HTML in the log view ([#17586](https://github.com/n8n-io/n8n/issues/17586)) ([46635c5](https://github.com/n8n-io/n8n/commit/46635c59418630c2f24fce5cb8c25e425eddc3c2))
* **editor:** Tweak configurable node width ([#17512](https://github.com/n8n-io/n8n/issues/17512)) ([3825f8a](https://github.com/n8n-io/n8n/commit/3825f8a806fcc67a33f43ce6ebd71b6a8023d7d8))
* **GitHub Document Loader Node:** Fix node loading issue ([#17494](https://github.com/n8n-io/n8n/issues/17494)) ([8fb3d8d](https://github.com/n8n-io/n8n/commit/8fb3d8d5870682af4b8b0c31949b5c1569a70d90))
* **Google Gemini Node:** Error when used as tool with "Message a model" operation ([#17491](https://github.com/n8n-io/n8n/issues/17491)) ([f30cc7b](https://github.com/n8n-io/n8n/commit/f30cc7b6cfba6998091f31fcd3012a971b3a2bb8))
* **Google Sheets Node:** Get Rows operation returns an empty string when the cell has a value of 0 ([#17642](https://github.com/n8n-io/n8n/issues/17642)) ([9808783](https://github.com/n8n-io/n8n/commit/980878398e9f6b498ba7079492e92bdba6fa6778))
* **MySQL Node:** Do not replace $ values with null ([#17327](https://github.com/n8n-io/n8n/issues/17327)) ([4b626e5](https://github.com/n8n-io/n8n/commit/4b626e528219c0610528a1119a2bb60c8442952d))
* **OpenAI Node:** Fix memory connector for assistant message ([#17501](https://github.com/n8n-io/n8n/issues/17501)) ([e51b056](https://github.com/n8n-io/n8n/commit/e51b056e3a8fd79c73b0a87eaf6595b7f03d546b))
* Prevent error when importing nodes with malformed collection params ([#17580](https://github.com/n8n-io/n8n/issues/17580)) ([4713827](https://github.com/n8n-io/n8n/commit/4713827813809065c8800adc7c0cd4bf42f54eeb))
* **RabbitMQ Trigger Node:** Respect the "Delete From Queue When" option with manual executions ([#17554](https://github.com/n8n-io/n8n/issues/17554)) ([2bd0aa3](https://github.com/n8n-io/n8n/commit/2bd0aa38e24dcada0777921d457586d35095ac42))
* **Telegram Node:** Determine the MIME type when downloading the file ([#17725](https://github.com/n8n-io/n8n/issues/17725)) ([a9c29e3](https://github.com/n8n-io/n8n/commit/a9c29e340adf370a65222600f3fac6884642c747))
* Update packages for security fixes ([#17733](https://github.com/n8n-io/n8n/issues/17733)) ([edeb8ef](https://github.com/n8n-io/n8n/commit/edeb8ef8a437f30a6c37826ad1eccb6a35a4d3bc))
* Update settings icons on canvas style ([#17636](https://github.com/n8n-io/n8n/issues/17636)) ([0338ebb](https://github.com/n8n-io/n8n/commit/0338ebb3dde3be4050ee869fb056f67827a764b2))
* **Webhook Node:** Don't wrap response in an iframe if it doesn't have HTML ([#17671](https://github.com/n8n-io/n8n/issues/17671)) ([69beafb](https://github.com/n8n-io/n8n/commit/69beafbf7127d6492fc875ab243e6f2e174e61ec))
### Features
* **core:** Increase Cron observability ([#17626](https://github.com/n8n-io/n8n/issues/17626)) ([08c38a7](https://github.com/n8n-io/n8n/commit/08c38a76f384642c09fab6fc47f76bffd532a5b8))
* **editor:** Add dragging and hiding for evaluation table columns ([#17587](https://github.com/n8n-io/n8n/issues/17587)) ([921cdb6](https://github.com/n8n-io/n8n/commit/921cdb6fd0ff11793a2ec08faee28b1c5842e25b))
* **editor:** Add follow up question nps ([#17459](https://github.com/n8n-io/n8n/issues/17459)) ([e18ffe8](https://github.com/n8n-io/n8n/commit/e18ffe809c044f2e10564669d96cf79779a8a279))
* **editor:** Add settings icons to the node on canvas ([#15467](https://github.com/n8n-io/n8n/issues/15467)) ([a2f21a7](https://github.com/n8n-io/n8n/commit/a2f21a76159e40de97c84c7604d3039d7e9a522e))
* **editor:** New users see whatsnew notification only if new ([#17409](https://github.com/n8n-io/n8n/issues/17409)) ([a1d2a55](https://github.com/n8n-io/n8n/commit/a1d2a55f7e6e04389cd8b86aacb9c78f2bffdc41))
* **editor:** Release the Focus Panel ([#17734](https://github.com/n8n-io/n8n/issues/17734)) ([a415dbf](https://github.com/n8n-io/n8n/commit/a415dbfd96c429f34e5de0a3572c7338d31321af))
* **editor:** Use remote filtering for error workflow search in settings ([#17624](https://github.com/n8n-io/n8n/issues/17624)) ([e1ef35a](https://github.com/n8n-io/n8n/commit/e1ef35a2b4a44c1ff9770b387fec9b5a3a742838))
* Proxy all RudderStack frontend telemetry events through the backend ([#17177](https://github.com/n8n-io/n8n/issues/17177)) ([5524b21](https://github.com/n8n-io/n8n/commit/5524b2137a0b54132df7dad1600c2e3054ed78c8))
* Respond to chat and wait for response ([#12546](https://github.com/n8n-io/n8n/issues/12546)) ([a98ed2c](https://github.com/n8n-io/n8n/commit/a98ed2ca495d5c86ebb61baad049592ba1bce3a6))
* **RSS Read Node:** Add support for custom response fields ([#16875](https://github.com/n8n-io/n8n/issues/16875)) ([d520059](https://github.com/n8n-io/n8n/commit/d520059ec36a9f0a578a60ddd8ea9811e76afd1f))
* Track inputs and outputs in Evaluations ([#17404](https://github.com/n8n-io/n8n/issues/17404)) ([c18fabb](https://github.com/n8n-io/n8n/commit/c18fabb419889d35bf70326f83e26300eaba0102))
# [1.104.0](https://github.com/n8n-io/n8n/compare/n8n@1.103.0...n8n@1.104.0) (2025-07-21)

View File

@ -0,0 +1,270 @@
# Cypress to Playwright Migration Guide
## Overview
This guide outlines the systematic approach for migrating Cypress tests to Playwright in the n8n codebase, based on successful migrations and lessons learned.
## 🎯 Migration Principles
### 1. **Architecture First**
- Follow the established 4-layer architecture: Tests → Composables → Page Objects → BasePage
- Use existing composables and page objects before creating new ones
- Maintain separation of concerns: business logic in composables, UI interactions in page objects
### 2. **Search Existing Patterns First**
- **ALWAYS** search for existing Playwright patterns before implementing new functionality
- Look for working examples in existing test files (e.g., `39-projects.spec.ts`)
- Check composables and page objects for existing methods
- Framework-specific patterns may differ (Cypress display names vs Playwright field names)
### 3. **Idempotent Test Design**
- Design tests to work regardless of initial state
- Use fresh project creation for tests that need empty states
- Create test prerequisites within the test when needed
- Avoid `@db:reset` dependencies in favor of project-based isolation
## 📋 Pre-Migration Checklist
### 1. **Environment Setup**
```bash
# Start isolated test environment
cd packages/testing/playwright
pnpm start:isolated
# Run tests with proper environment
N8N_BASE_URL=http://localhost:5679 npx playwright test --reporter=list
```
### 2. **Study Existing Patterns**
- Review `CONTRIBUTING.md` for architecture guidelines
- Examine working test files (e.g., `1-workflows.spec.ts`, `39-projects.spec.ts`)
- Check available composables in `composables/` directory
- Review page objects in `pages/` directory
### 3. **Understand Framework Differences**
- **Cypress**: Uses display names (`'Internal Integration Secret'`)
- **Playwright**: Uses field names (`'apiKey'`)
- **Navigation**: Direct page navigation often more reliable than complex UI interactions
- **Selectors**: Prefer `data-test-id` over text-based selectors
## 🔄 Migration Process
### Step 1: Scaffold the Test File
```typescript
// 1. Create test file with proper imports
import { test, expect } from '../fixtures/base';
import {
// Import constants from existing patterns
NOTION_NODE_NAME,
NEW_NOTION_ACCOUNT_NAME,
// ... other constants
} from '../config/constants';
// 2. Add beforeEach setup if needed
test.describe('Feature Name', () => {
test.beforeEach(async ({ api, n8n }) => {
await api.enableFeature('sharing');
await api.enableFeature('folders');
// ... other feature flags
await n8n.goHome();
});
// 3. Scaffold all tests from Cypress file
test('should do something', async ({ n8n }) => {
// TODO: Implement based on Cypress version
console.log('Test scaffolded - ready for implementation');
});
});
```
### Step 2: Research Existing Patterns
```bash
# Search for existing implementations
grep -r "addCredentialToProject" packages/testing/playwright/
grep -r "createProject" packages/testing/playwright/
grep -r "workflowComposer" packages/testing/playwright/
```
### Step 3: Implement Working Tests First
- Start with tests that have clear existing patterns
- Use composables for high-level operations (project creation, navigation)
- Use direct DOM interactions for form filling when composables don't match
- Implement one test at a time and verify it works
### Step 4: Handle Complex UI Interactions
- **Node Creation Issues**: Close NDV after adding first node to prevent overlay blocking
- **Universal Add Button**: Use direct navigation when button interactions fail
- **Modal Overlays**: Use route interception for error testing
- **Multiple Elements**: Use specific selectors to avoid strict mode violations
## 🛠️ Common Patterns
### Project-Based Testing
```typescript
// ✅ Good: Use existing composable
const { projectName } = await n8n.projectComposer.createProject();
await n8n.projectComposer.addCredentialToProject(
projectName,
'Notion API',
'apiKey', // Use field name, not display name
'test_value'
);
```
### Direct Navigation
```typescript
// ✅ Good: Direct navigation when UI interactions fail
await n8n.page.goto('/home/credentials/create');
await n8n.page.goto('/workflow/new');
```
### Error Testing with Route Interception
```typescript
// ✅ Good: Force errors for notification testing
await n8n.page.route('**/rest/credentials', route => {
route.abort();
});
```
### Node Creation with NDV Handling
```typescript
// ✅ Good: Handle NDV auto-opening
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.ndv.close(); // Close NDV that opens automatically
await n8n.canvas.addNode(NOTION_NODE_NAME);
```
## 🚨 Common Pitfalls
### 1. **Not Checking Existing Patterns**
```typescript
// ❌ Bad: Implementing without checking existing patterns
await n8n.page.getByText('Internal Integration Secret').fill('value');
// ✅ Good: Use existing composable with correct field name
await n8n.projectComposer.addCredentialToProject(
projectName, 'Notion API', 'apiKey', 'value'
);
```
### 2. **Ignoring Framework Differences**
```typescript
// ❌ Bad: Assuming Cypress patterns work in Playwright
await n8n.credentialsModal.connectionParameter('Internal Integration Secret').fill('value');
// ✅ Good: Use Playwright field names
await n8n.page.getByTestId('parameter-input-field').fill('value');
```
### 3. **Complex UI Interactions When Simple Navigation Works**
```typescript
// ❌ Bad: Complex button clicking when direct navigation works
await n8n.workflows.clickAddWorkflowButton();
await n8n.page.waitForLoadState();
// ✅ Good: Direct navigation
await n8n.page.goto('/workflow/new');
await n8n.page.waitForLoadState('networkidle');
```
### 4. **Not Handling UI Blocking**
```typescript
// ❌ Bad: Not handling NDV auto-opening
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.canvas.addNode(NOTION_NODE_NAME); // This will fail
// ✅ Good: Close NDV after first node
await n8n.canvas.addNode(SCHEDULE_TRIGGER_NODE_NAME);
await n8n.ndv.close();
await n8n.canvas.addNode(NOTION_NODE_NAME);
```
## 📝 Testing Strategy
### 1. **Start Simple**
- Begin with basic navigation and page verification tests
- Use existing composables for common operations
- Verify each test works before moving to complex scenarios
### 2. **Incremental Implementation**
- Scaffold all tests first with placeholders
- Implement one test at a time
- Use `console.log` for placeholder tests to maintain passing test suite
### 3. **Debugging Approach**
```typescript
// Add pauses for debugging
await n8n.page.pause();
// Use headed mode for visual debugging
SHOW_BROWSER=true npx playwright test
// Use specific test selection
npx playwright test -g "test name" --reporter=list
```
### 4. **Verification Strategy**
- Run individual tests during development
- Run full test suite after each major change
- Use `--reporter=list` for clear output during development
## 🔧 Environment Configuration
### VS Code Settings
```json
{
"playwright.env": {
"N8N_BASE_URL": "http://localhost:5679",
"SHOW_BROWSER": "true",
"RESET_E2E_DB": "true"
}
}
```
### Package.json Scripts
```json
{
"scripts": {
"start:isolated": "cd ..; N8N_PORT=5679 N8N_USER_FOLDER=/tmp/n8n-test-$(date +%s) E2E_TESTS=true pnpm start",
"test:local": "RESET_E2E_DB=true N8N_BASE_URL=http://localhost:5679 start-server-and-test 'pnpm start:isolated' http://localhost:5679/favicon.ico 'sleep 1 && pnpm test:standard --workers 4 --repeat-each 5'"
}
}
```
## 📊 Success Metrics
### Migration Complete When:
- [ ] All tests from Cypress file are scaffolded
- [ ] All tests pass consistently
- [ ] Tests use existing composables where appropriate
- [ ] Tests follow established patterns
- [ ] No `@db:reset` dependencies (unless absolutely necessary)
- [ ] Tests are idempotent and can run in any order
- [ ] Complex UI interactions are handled properly
### Quality Checklist:
- [ ] Tests use proper error handling
- [ ] Tests include appropriate assertions
- [ ] Tests follow naming conventions
- [ ] Tests include proper comments
- [ ] Tests use constants for repeated values
- [ ] Tests handle dynamic data properly
## 🎯 Best Practices Summary
1. **Search First**: Always look for existing patterns before implementing
2. **Use Composables**: Leverage existing business logic composables
3. **Direct Navigation**: Prefer direct page navigation over complex UI interactions
4. **Handle UI Blocking**: Close modals/NDV when adding multiple nodes
5. **Framework Awareness**: Understand differences between Cypress and Playwright
6. **Incremental Approach**: Implement one test at a time
7. **Idempotent Design**: Make tests work regardless of initial state
8. **Proper Debugging**: Use pauses and headed mode for troubleshooting
## 📚 Resources
- [Playwright Test Documentation](https://playwright.dev/docs/intro)
- [n8n Playwright Contributing Guide](./packages/testing/playwright/CONTRIBUTING.md)
- [Existing Test Examples](./packages/testing/playwright/tests/)
- [Composables Reference](./packages/testing/playwright/composables/)
- [Page Objects Reference](./packages/testing/playwright/pages/)

View File

@ -10,7 +10,9 @@ const workflowSharingModal = new WorkflowSharingModal();
const multipleWorkflowsCount = 5;
describe('Workflows', () => {
// Migrated to Playwright
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
describe.skip('Workflows', () => {
beforeEach(() => {
cy.visit(WorkflowsPage.url);
});

View File

@ -1,10 +1,10 @@
import { WorkflowPage, NDV } from '../pages';
import { getVisibleSelect } from '../utils';
import {
MANUAL_TRIGGER_NODE_NAME,
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
} from './../constants';
import { WorkflowPage, NDV } from '../pages';
import { getVisibleSelect } from '../utils';
const workflowPage = new WorkflowPage();
const ndv = new NDV();

View File

@ -41,33 +41,33 @@ describe('Execution', () => {
// Check canvas nodes after 1st step (workflow passed the manual trigger node
workflowPage.getters
.canvasNodeByName('Manual')
.within(() => cy.get('svg[data-icon=check]'))
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('svg[data-icon=check]').should('not.exist'));
.within(() => cy.get('svg[data-icon=node-success]').should('not.exist'));
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('svg[data-icon=refresh-cw]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('svg[data-icon=check]').should('not.exist'));
.within(() => cy.get('svg[data-icon=node-success]').should('not.exist'));
cy.wait(2000);
// Check canvas nodes after 2nd step (waiting node finished its execution and the http request node is about to start)
workflowPage.getters
.canvasNodeByName('Manual')
.within(() => cy.get('svg[data-icon=check]'))
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('svg[data-icon=check]'))
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('svg[data-icon=check]'))
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
successToast().should('be.visible');
@ -101,18 +101,18 @@ describe('Execution', () => {
// Check canvas nodes after 1st step (workflow passed the manual trigger node
workflowPage.getters
.canvasNodeByName('Manual')
.within(() => cy.get('svg[data-icon=check]'))
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('svg[data-icon=check]').should('not.exist'));
.within(() => cy.get('svg[data-icon=node-success]').should('not.exist'));
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('svg[data-icon=refresh-cw]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('svg[data-icon=check]').should('not.exist'));
.within(() => cy.get('svg[data-icon=node-success]').should('not.exist'));
successToast().should('be.visible');
clearNotifications();
@ -123,7 +123,7 @@ describe('Execution', () => {
// Check canvas nodes after workflow stopped
workflowPage.getters
.canvasNodeByName('Manual')
.within(() => cy.get('svg[data-icon=check]'))
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
workflowPage.getters
@ -132,7 +132,7 @@ describe('Execution', () => {
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('svg[data-icon=check]').should('not.exist'));
.within(() => cy.get('svg[data-icon=node-success]').should('not.exist'));
successToast().should('be.visible');
@ -181,29 +181,29 @@ describe('Execution', () => {
// Check canvas nodes after 1st step (workflow passed the manual trigger node
workflowPage.getters
.canvasNodeByName('Webhook')
.within(() => cy.get('svg[data-icon=check]'))
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('svg[data-icon=check]').should('not.exist'));
.within(() => cy.get('svg[data-icon=node-success]').should('not.exist'));
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('svg[data-icon=refresh-cw]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('svg[data-icon=check]').should('not.exist'));
.within(() => cy.get('svg[data-icon=node-success]').should('not.exist'));
cy.wait(2000);
// Check canvas nodes after 2nd step (waiting node finished its execution and the http request node is about to start)
workflowPage.getters
.canvasNodeByName('Webhook')
.within(() => cy.get('svg[data-icon=check]'))
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('svg[data-icon=check]'))
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
successToast().should('be.visible');
@ -578,11 +578,11 @@ describe('Execution', () => {
// Check that the previous nodes executed successfully
workflowPage.getters
.canvasNodeByName('DebugHelper')
.within(() => cy.get('svg[data-icon=check]'))
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Filter')
.within(() => cy.get('svg[data-icon=check]'))
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
errorToast().should('contain', 'Problem in node Telegram');
@ -596,7 +596,7 @@ describe('Execution', () => {
workflowPage.getters
.canvasNodeByName('Edit Fields')
.within(() => cy.get('svg[data-icon=check]'))
.within(() => cy.get('svg[data-icon=node-success]'))
.should('exist');
workflowPage.getters.canvasNodeByName('Edit Fields').dblclick();

View File

@ -1,11 +1,11 @@
import generateOTPToken from 'cypress-otp';
import { MainSidebar } from './../pages/sidebar/main-sidebar';
import { INSTANCE_OWNER, INSTANCE_ADMIN, BACKEND_BASE_URL } from '../constants';
import { SigninPage } from '../pages';
import { MfaLoginPage } from '../pages/mfa-login';
import { successToast } from '../pages/notifications';
import { PersonalSettingsPage } from '../pages/settings-personal';
import { MainSidebar } from './../pages/sidebar/main-sidebar';
const MFA_SECRET = 'KVKFKRCPNZQUYMLXOVYDSQKJKZDTSRLD';

View File

@ -35,7 +35,7 @@ describe('Personal Settings', () => {
successToast().find('.el-notification__closeBtn').click();
});
});
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
it('not allow malicious values for personal data', () => {
cy.visit('/settings/personal');
INVALID_NAMES.forEach((name) => {

View File

@ -3,8 +3,9 @@ import { clearNotifications } from '../pages/notifications';
import {
getNpsSurvey,
getNpsSurveyClose,
getNpsSurveyEmail,
getNpsSurveyFeedback,
getNpsSurveyRatings,
getNpsSurveySubmit,
} from '../pages/npsSurvey';
import { WorkflowPage } from '../pages/workflow';
@ -22,7 +23,7 @@ describe('NpsSurvey', () => {
cy.signin(INSTANCE_ADMIN);
});
it('shows nps survey to recently activated user and can submit email ', () => {
it('shows nps survey to recently activated user and can submit feedback ', () => {
cy.intercept('/rest/settings', { middleware: true }, (req) => {
req.on('response', (res) => {
if (res.body.data) {
@ -31,6 +32,8 @@ describe('NpsSurvey', () => {
config: {
key: 'test',
url: 'https://telemetry-test.n8n.io',
proxy: 'http://localhost:5678/rest/telemetry/proxy',
sourceConfig: 'http://localhost:5678/rest/telemetry/rudderstack',
},
};
}
@ -54,8 +57,8 @@ describe('NpsSurvey', () => {
getNpsSurveyRatings().find('button').should('have.length', 11);
getNpsSurveyRatings().find('button').first().click();
getNpsSurveyEmail().find('input').type('test@n8n.io');
getNpsSurveyEmail().find('button').click();
getNpsSurveyFeedback().find('textarea').type('n8n is the best');
getNpsSurveySubmit().find('button').click();
// test that modal does not show up again until 6 months later
workflowPage.actions.visit(true, NOW + ONE_DAY);
@ -77,6 +80,8 @@ describe('NpsSurvey', () => {
config: {
key: 'test',
url: 'https://telemetry-test.n8n.io',
proxy: 'http://localhost:5678/rest/telemetry/proxy',
sourceConfig: 'http://localhost:5678/rest/telemetry/rudderstack',
},
};
}

View File

@ -443,7 +443,7 @@ describe('AI Assistant Credential Help', () => {
aiAssistant.getters.credentialEditAssistantButton().should('not.exist');
credentialsModal.getters.credentialAuthTypeRadioButtons().eq(1).click();
credentialsModal.getters.credentialInputs().should('have.length', 1);
credentialsModal.getters.credentialInputs().should('have.length', 3);
aiAssistant.getters.credentialEditAssistantButton().should('exist');
});

View File

@ -94,7 +94,7 @@ $input.item()
return []
`);
getParameter().get('.cm-lintRange-error').should('have.length', 5);
getParameter().get('.cm-lintRange-error').should('have.length.gte', 5);
getParameter().contains('all').realHover();
cy.get('.cm-tooltip-lint').should(
'have.text',

View File

@ -11,7 +11,7 @@
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"lint:fix": "eslint . --fix",
"develop": "cd ..; pnpm dev:e2e:server",
"start": "cd ..; pnpm start"
},

View File

@ -305,7 +305,7 @@ export class NDV extends BasePage {
this.actions.typeIntoParameterInput(fieldName, invalidExpression ?? "{{ $('unknown')", {
parseSpecialCharSequences: false,
});
this.actions.validateExpressionPreview(fieldName, "node doesn't exist");
this.actions.validateExpressionPreview(fieldName, 'No path back to node');
},
openSettings: () => {
this.getters.nodeSettingsTab().click();

View File

@ -6,7 +6,8 @@ export const getNpsSurvey = () => cy.getByTestId('nps-survey-modal');
export const getNpsSurveyRatings = () => cy.getByTestId('nps-survey-ratings');
export const getNpsSurveyEmail = () => cy.getByTestId('nps-survey-email');
export const getNpsSurveyFeedback = () => cy.getByTestId('nps-survey-feedback');
export const getNpsSurveySubmit = () => cy.getByTestId('nps-survey-feedback-button');
export const getNpsSurveyClose = () =>
cy.getByTestId('nps-survey-modal').find('button.el-drawer__close-btn');

View File

@ -14,9 +14,13 @@ export const installFirstCommunityNode = (nodeName: string) => {
};
export const confirmCommunityNodeUpdate = () => {
cy.getByTestId('communityPackageManageConfirm-modal').find('button').eq(1).click();
cy.getByTestId('communityPackageManageConfirm-modal')
.contains('button', 'Confirm update')
.click();
};
export const confirmCommunityNodeUninstall = () => {
cy.getByTestId('communityPackageManageConfirm-modal').find('button').eq(1).click();
cy.getByTestId('communityPackageManageConfirm-modal')
.contains('button', 'Confirm uninstall')
.click();
};

View File

@ -149,7 +149,7 @@ Cypress.Commands.add('grantBrowserPermissions', (...permissions: string[]) => {
});
Cypress.Commands.add('readClipboard', () =>
cy.window().then((win) => win.navigator.clipboard.readText()),
cy.window().then(async (win) => await win.navigator.clipboard.readText()),
);
Cypress.Commands.add('paste', { prevSubject: true }, (selector, pastePayload) => {

View File

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "1.104.0",
"version": "1.105.0",
"private": true,
"engines": {
"node": ">=22.16",
@ -11,9 +11,6 @@
"prepare": "node scripts/prepare.mjs",
"preinstall": "node scripts/block-npm-install.js",
"build": "turbo run build",
"build:backend": "turbo run build:backend",
"build:frontend": "turbo run build:frontend",
"build:nodes": "turbo run build:nodes",
"build:n8n": "node scripts/build-n8n.mjs",
"build:deploy": "node scripts/build-n8n.mjs",
"build:docker": "node scripts/build-n8n.mjs && node scripts/dockerize-n8n.mjs",
@ -28,16 +25,13 @@
"dev:e2e": "cd cypress && pnpm run test:e2e:dev",
"debug:flaky:e2e": "cd cypress && pnpm run test:flaky",
"dev:e2e:server": "run-p start dev:fe:editor",
"clean": "turbo run clean --parallel",
"clean": "turbo run clean",
"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",
"lint:nodes": "turbo run lint:nodes",
"lint:frontend": "turbo run lint:frontend",
"lint:testing": "turbo run lint:testing",
"lint:affected": "turbo run lint --affected",
"lint:fix": "turbo run lint:fix",
"optimize-svg": "find ./packages -name '*.svg' ! -name 'pipedrive.svg' -print0 | xargs -0 -P16 -L20 npx svgo",
"setup-backend-module": "node scripts/ensure-zx.mjs && zx scripts/backend-module/setup.mjs",
"start": "run-script-os",
@ -45,12 +39,11 @@
"start:tunnel": "./packages/cli/bin/n8n start --tunnel",
"start:windows": "cd packages/cli/bin && n8n",
"test": "JEST_JUNIT_CLASSNAME={filepath} turbo run test",
"test:backend": "turbo run test:backend --concurrency=1",
"test:frontend": "turbo run test:frontend --concurrency=1",
"test:nodes": "turbo run test:nodes --concurrency=1",
"test:ci": "turbo run test --continue --concurrency=1",
"test:affected": "turbo run test --affected --concurrency=1",
"test:with:docker": "pnpm --filter=n8n-playwright run test:standard",
"test:show:report": "pnpm --filter=n8n-playwright exec playwright show-report",
"watch": "turbo run watch --parallel",
"watch": "turbo run watch",
"webhook": "./packages/cli/bin/n8n webhook",
"worker": "./packages/cli/bin/n8n worker"
},
@ -91,10 +84,11 @@
],
"overrides": {
"@azure/identity": "^4.3.0",
"@n8n/typeorm>@sentry/node": "catalog:",
"@types/node": "^20.17.50",
"chokidar": "^4.0.1",
"esbuild": "^0.24.0",
"multer": "^2.0.1",
"multer": "^2.0.2",
"prebuild-install": "7.1.3",
"pug": "^3.0.3",
"semver": "^7.5.4",
@ -108,7 +102,8 @@
"brace-expansion@1": "1.1.12",
"brace-expansion@2": "2.0.2",
"date-fns": "2.30.0",
"date-fns-tz": "2.0.0"
"date-fns-tz": "2.0.0",
"form-data": "4.0.4"
},
"patchedDependencies": {
"bull@4.16.4": "patches/bull@4.16.4.patch",
@ -122,7 +117,9 @@
"js-base64": "patches/js-base64.patch",
"ics": "patches/ics.patch",
"minifaker": "patches/minifaker.patch",
"z-vue-scan": "patches/z-vue-scan.patch"
"z-vue-scan": "patches/z-vue-scan.patch",
"@lezer/highlight": "patches/@lezer__highlight.patch",
"v-code-diff": "patches/v-code-diff.patch"
}
}
}

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/ai-workflow-builder",
"version": "0.14.0",
"version": "0.15.0",
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",
@ -11,7 +11,7 @@
"test:coverage": "jest --coverage",
"test:watch": "jest --watch",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"lint:fix": "eslint . --fix",
"watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\"",
"deps:graph": "madge src/index.ts --image deps-graph.svg",
"deps:graph:service": "madge src/ai-workflow-builder-agent.service.ts --image deps-service.svg",

View File

@ -167,10 +167,10 @@ export class AiWorkflowBuilderService {
return this.agent;
}
async *chat(payload: ChatPayload, user?: IUser) {
async *chat(payload: ChatPayload, user?: IUser, abortSignal?: AbortSignal) {
const agent = await this.getAgent(user);
for await (const output of agent.chat(payload, user?.id?.toString())) {
for await (const output of agent.chat(payload, user?.id?.toString(), abortSignal)) {
yield output;
}
}

View File

@ -1,6 +1,7 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { AIMessage, ToolMessage } from '@langchain/core/messages';
import { HumanMessage, RemoveMessage } from '@langchain/core/messages';
import type { ToolMessage } from '@langchain/core/messages';
import { AIMessage, HumanMessage, RemoveMessage } from '@langchain/core/messages';
import type { RunnableConfig } from '@langchain/core/runnables';
import type { LangChainTracer } from '@langchain/core/tracers/tracer_langchain';
import { StateGraph, MemorySaver, END } from '@langchain/langgraph';
import type { Logger } from '@n8n/backend-common';
@ -180,71 +181,73 @@ export class WorkflowBuilderAgent {
: crypto.randomUUID();
}
async *chat(payload: ChatPayload, userId?: string) {
private getDefaultWorkflowJSON(payload: ChatPayload): SimpleWorkflow {
return (
(payload.workflowContext?.currentWorkflow as SimpleWorkflow) ?? {
nodes: [],
connections: {},
}
);
}
async *chat(payload: ChatPayload, userId?: string, abortSignal?: AbortSignal) {
const agent = this.createWorkflow().compile({ checkpointer: this.checkpointer });
const workflowId = payload.workflowContext?.currentWorkflow?.id;
// Generate thread ID from workflowId and userId
// This ensures one session per workflow per user
const threadId = WorkflowBuilderAgent.generateThreadId(workflowId, userId);
// Configure thread for checkpointing
const threadConfig = {
const threadConfig: RunnableConfig = {
configurable: {
thread_id: threadId,
},
};
const streamConfig = {
...threadConfig,
streamMode: ['updates', 'custom'],
recursionLimit: 30,
signal: abortSignal,
callbacks: this.tracer ? [this.tracer] : undefined,
} as RunnableConfig;
// Check if this is a subsequent message
// If so, update the workflowJSON with the current editor state
const existingCheckpoint = await this.checkpointer.getTuple(threadConfig);
let stream;
if (!existingCheckpoint?.checkpoint) {
// First message - use initial state
const initialState: typeof WorkflowState.State = {
const stream = await agent.stream(
{
messages: [new HumanMessage({ content: payload.message })],
workflowJSON: (payload.workflowContext?.currentWorkflow as SimpleWorkflow) ?? {
nodes: [],
connections: {},
},
workflowJSON: this.getDefaultWorkflowJSON(payload),
workflowOperations: [],
workflowContext: payload.workflowContext,
};
},
streamConfig,
);
stream = await agent.stream(initialState, {
...threadConfig,
streamMode: ['updates', 'custom'],
recursionLimit: 30,
callbacks: this.tracer ? [this.tracer] : undefined,
});
} else {
// Subsequent message - update the state with current workflow
const stateUpdate: Partial<typeof WorkflowState.State> = {
messages: [new HumanMessage({ content: payload.message })],
workflowOperations: [], // Clear any pending operations from previous message
workflowContext: payload.workflowContext,
workflowJSON: { nodes: [], connections: {} }, // Default to empty workflow
};
if (payload.workflowContext?.currentWorkflow) {
stateUpdate.workflowJSON = payload.workflowContext?.currentWorkflow as SimpleWorkflow;
try {
const streamProcessor = createStreamProcessor(stream);
for await (const output of streamProcessor) {
yield output;
}
} catch (error) {
if (
error &&
typeof error === 'object' &&
'message' in error &&
typeof error.message === 'string' &&
// This is naive, but it's all we get from LangGraph AbortError
['Abort', 'Aborted'].includes(error.message)
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const messages = (await agent.getState(threadConfig)).values.messages as Array<
AIMessage | HumanMessage | ToolMessage
>;
// Stream with just the new message
stream = await agent.stream(stateUpdate, {
...threadConfig,
streamMode: ['updates', 'custom'],
recursionLimit: 80,
callbacks: this.tracer ? [this.tracer] : undefined,
});
}
// Use the stream processor utility to handle chunk processing
const streamProcessor = createStreamProcessor(stream);
for await (const output of streamProcessor) {
yield output;
// Handle abort errors gracefully
const abortedAiMessage = new AIMessage({
content: '[Task aborted]',
id: crypto.randomUUID(),
});
// TODO: Should we clear tool calls that are in progress?
await agent.updateState(threadConfig, { messages: [...messages, abortedAiMessage] });
return;
}
throw error;
}
}
@ -256,7 +259,7 @@ export class WorkflowBuilderAgent {
if (workflowId) {
const threadId = WorkflowBuilderAgent.generateThreadId(workflowId, userId);
const threadConfig = {
const threadConfig: RunnableConfig = {
configurable: {
thread_id: threadId,
},

View File

@ -11,7 +11,8 @@
"paths": {
"@/*": ["./*"]
},
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo"
"tsBuildInfoFile": "dist/typecheck.tsbuildinfo",
"types": ["node", "jest"]
},
"include": ["src/**/*.ts", "test/**/*.ts", "evaluations/**/*.ts"]
}

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/api-types",
"version": "0.38.0",
"version": "0.39.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
@ -9,7 +9,7 @@
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:dev": "jest --watch"

View File

@ -13,6 +13,8 @@ export interface IVersionNotificationSettings {
export interface ITelemetryClientConfig {
url: string;
key: string;
proxy: string;
sourceConfig: string;
}
export interface ITelemetrySettings {

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-common",
"version": "0.14.0",
"version": "0.15.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
@ -9,7 +9,7 @@
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:dev": "jest --watch"
@ -29,9 +29,12 @@
"n8n-workflow": "workspace:^",
"picocolors": "catalog:",
"reflect-metadata": "catalog:",
"winston": "3.14.2"
"winston": "3.14.2",
"yargs-parser": "21.1.1"
},
"devDependencies": {
"@n8n/typescript-config": "workspace:*"
"@n8n/typescript-config": "workspace:*",
"@types/yargs-parser": "21.0.0",
"zod": "catalog:"
}
}

View File

@ -0,0 +1,117 @@
import { mock } from 'jest-mock-extended';
import z from 'zod';
import { CliParser } from '../cli-parser';
describe('parse', () => {
it('should parse `argv` without flags schema', () => {
const cliParser = new CliParser(mock());
const result = cliParser.parse({ argv: ['node', 'script.js', 'arg1', 'arg2'] });
expect(result).toEqual({ flags: {}, args: ['arg1', 'arg2'] });
});
it('should parse `argv` with flags schema', () => {
const cliParser = new CliParser(mock());
const flagsSchema = z.object({
verbose: z.boolean().optional(),
name: z.string().optional(),
});
const result = cliParser.parse({
argv: ['node', 'script.js', '--verbose', '--name', 'test', 'arg1'],
flagsSchema,
});
expect(result).toEqual({
flags: { verbose: true, name: 'test' },
args: ['arg1'],
});
});
it('should ignore flags not defined in schema', () => {
const cliParser = new CliParser(mock());
const flagsSchema = z.object({
name: z.string().optional(),
// ignored is absent
});
const result = cliParser.parse({
argv: ['node', 'script.js', '--name', 'test', '--ignored', 'value', 'arg1'],
flagsSchema,
});
expect(result).toEqual({
flags: {
name: 'test',
// ignored is absent
},
args: ['arg1'],
});
});
it('should handle a numeric value for `--id` flag', () => {
const cliParser = new CliParser(mock());
const result = cliParser.parse({
argv: ['node', 'script.js', '--id', '123', 'arg1'],
flagsSchema: z.object({
id: z.string(),
}),
});
expect(result).toEqual({
flags: { id: '123' },
args: ['arg1'],
});
});
it('should handle positional arguments', () => {
const cliParser = new CliParser(mock());
const result = cliParser.parse({
argv: ['node', 'script.js', '123', 'true'],
});
expect(result.args).toEqual(['123', 'true']);
expect(typeof result.args[0]).toBe('string');
expect(typeof result.args[1]).toBe('string');
});
it('should handle required flags with aliases', () => {
const cliParser = new CliParser(mock());
const flagsSchema = z.object({
name: z.string(),
});
// @ts-expect-error zod was monkey-patched to support aliases
flagsSchema.shape.name._def._alias = 'n';
const result = cliParser.parse({
argv: ['node', 'script.js', '-n', 'test', 'arg1'],
flagsSchema,
});
expect(result).toEqual({
flags: { name: 'test' },
args: ['arg1'],
});
});
it('should handle optional flags with aliases', () => {
const cliParser = new CliParser(mock());
const flagsSchema = z.object({
name: z.optional(z.string()),
});
// @ts-expect-error zod was monkey-patched to support aliases
flagsSchema.shape.name._def.innerType._def._alias = 'n';
const result = cliParser.parse({
argv: ['node', 'script.js', '-n', 'test', 'arg1'],
flagsSchema,
});
expect(result).toEqual({
flags: { name: 'test' },
args: ['arg1'],
});
});
});

View File

@ -0,0 +1,63 @@
import { Service } from '@n8n/di';
import argvParser from 'yargs-parser';
import type { z } from 'zod';
import { Logger } from './logging';
type CliInput<Flags extends z.ZodRawShape> = {
argv: string[];
flagsSchema?: z.ZodObject<Flags>;
description?: string;
examples?: string[];
};
type ParsedArgs<Flags = Record<string, unknown>> = {
flags: Flags;
args: string[];
};
@Service()
export class CliParser {
constructor(private readonly logger: Logger) {}
parse<Flags extends z.ZodRawShape>(
input: CliInput<Flags>,
): ParsedArgs<z.infer<z.ZodObject<Flags>>> {
// eslint-disable-next-line id-denylist
const { _: rest, ...rawFlags } = argvParser(input.argv, { string: ['id'] });
let flags = {} as z.infer<z.ZodObject<Flags>>;
if (input.flagsSchema) {
for (const key in input.flagsSchema.shape) {
const flagSchema = input.flagsSchema.shape[key];
let schemaDef = flagSchema._def as z.ZodTypeDef & {
typeName: string;
innerType?: z.ZodType;
_alias?: string;
};
if (schemaDef.typeName === 'ZodOptional' && schemaDef.innerType) {
schemaDef = schemaDef.innerType._def as typeof schemaDef;
}
const alias = schemaDef._alias;
if (alias?.length && !(key in rawFlags) && rawFlags[alias]) {
rawFlags[key] = rawFlags[alias] as unknown;
}
}
flags = input.flagsSchema.parse(rawFlags);
}
const args = rest.map(String).slice(2);
this.logger.debug('Received CLI command', {
execPath: rest[0],
scriptPath: rest[1],
args,
flags,
});
return { flags, args };
}
}

View File

@ -7,3 +7,4 @@ export { Logger } from './logging/logger';
export { ModuleRegistry } from './modules/module-registry';
export { ModulesConfig, ModuleName } from './modules/modules.config';
export { isContainedWithin, safeJoinPath } from './utils/path-util';
export { CliParser } from './cli-parser';

View File

@ -1,6 +1,7 @@
import { ModuleMetadata } from '@n8n/decorators';
import type { EntityClass, ModuleSettings } from '@n8n/decorators';
import { Container, Service } from '@n8n/di';
import { existsSync } from 'fs';
import path from 'path';
import { MissingModuleError } from './errors/missing-module.error';
@ -54,11 +55,13 @@ export class ModuleRegistry {
// docker + tests
const n8nPackagePath = require.resolve('n8n/package.json');
const n8nRoot = path.dirname(n8nPackagePath);
const dir = process.env.NODE_ENV === 'test' ? 'src' : 'dist';
const srcDirExists = existsSync(path.join(n8nRoot, 'src'));
const dir = process.env.NODE_ENV === 'test' && srcDirExists ? 'src' : 'dist';
modulesDir = path.join(n8nRoot, dir, 'modules');
} catch {
// local dev
modulesDir = path.resolve(__dirname, '../../../../cli/dist/modules');
// n8n binary is inside the bin folder, so we need to go up two levels
modulesDir = path.resolve(process.argv[1], '../../dist/modules');
}
for (const moduleName of modules ?? this.eligibleModules) {

View File

@ -8,5 +8,12 @@
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts"],
"references": [
{ "path": "../../workflow/tsconfig.build.cjs.json" },
{ "path": "../config/tsconfig.build.json" },
{ "path": "../constants/tsconfig.build.json" },
{ "path": "../decorators/tsconfig.build.json" },
{ "path": "../di/tsconfig.build.json" }
]
}

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/backend-test-utils",
"version": "0.7.0",
"version": "0.8.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
@ -9,10 +9,10 @@
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:dev": "jest --watch"
"test": "echo \"WARNING: no test specified\" && exit 0",
"test:dev": "echo \"WARNING: no test specified\" && exit 0"
},
"main": "dist/index.js",
"module": "src/index.ts",

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/n8n-benchmark",
"version": "1.19.0",
"version": "1.20.0",
"description": "Cli for running benchmark tests for n8n",
"main": "dist/index",
"scripts": {
@ -8,9 +8,9 @@
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"lint:fix": "eslint . --fix",
"start": "./bin/n8n-benchmark",
"test": "echo \"Error: no test specified\" && exit 1",
"test": "echo \"WARNING: no test specified\" && exit 0",
"typecheck": "tsc --noEmit",
"benchmark": "zx scripts/run.mjs",
"benchmark-in-cloud": "pnpm benchmark --env cloud",

View File

@ -1,64 +0,0 @@
services:
mockapi:
image: wiremock/wiremock:3.9.1
ports:
- '8088:8080'
volumes:
- ${MOCK_API_DATA_PATH}/mappings:/home/wiremock/mappings
postgres:
image: postgres:16.4
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
# Task Runner config
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MODE=internal
# Enable Insights
- N8N_ENABLED_MODULES=insights
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

@ -1,15 +0,0 @@
#!/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

@ -35,8 +35,6 @@ services:
# Task Runner config
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MODE=internal
# Disable Insights
- N8N_DISABLED_MODULES=insights
ports:
- 5678:5678
volumes:

View File

@ -1,217 +0,0 @@
services:
mockapi:
image: wiremock/wiremock:3.9.1
ports:
- '8088:8080'
volumes:
- ${MOCK_API_DATA_PATH}/mappings:/home/wiremock/mappings
redis:
image: redis:6.2.14-alpine
restart: always
ports:
- 6379:6379
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 1s
timeout: 3s
postgres:
image: postgres:16.4
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
# Task Runner config
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MODE=internal
# Enable Insights
- N8N_ENABLED_MODULES=insights
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
# Task Runner config
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MODE=internal
# Enable Insights
- N8N_ENABLED_MODULES=insights
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
- N8N_PROXY_HOPS=1
- 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
# Task Runner config
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MODE=internal
# Enable Insights
- N8N_ENABLED_MODULES=insights
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
- N8N_PROXY_HOPS=1
- 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
# Task Runner config
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MODE=internal
# Enable Insights
- N8N_ENABLED_MODULES=insights
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:1.27.2
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

@ -1,24 +0,0 @@
events {}
http {
client_max_body_size 50M;
access_log off;
error_log /dev/stderr warn;
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

@ -1,15 +0,0 @@
#!/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

@ -53,8 +53,6 @@ services:
# Task Runner config
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MODE=internal
# Disable Insights
- N8N_DISABLED_MODULES=insights
command: worker
volumes:
- ${RUN_DIR}/n8n-worker1:/n8n
@ -90,8 +88,6 @@ services:
# Task Runner config
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MODE=internal
# Disable Insights
- N8N_DISABLED_MODULES=insights
command: worker
volumes:
- ${RUN_DIR}/n8n-worker2:/n8n
@ -130,8 +126,6 @@ services:
# Task Runner config
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MODE=internal
# Disable Insights
- N8N_DISABLED_MODULES=insights
volumes:
- ${RUN_DIR}/n8n-main2:/n8n
depends_on:
@ -172,8 +166,6 @@ services:
# Task Runner config
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MODE=internal
# Disable Insights
- N8N_DISABLED_MODULES=insights
volumes:
- ${RUN_DIR}/n8n-main1:/n8n
depends_on:

View File

@ -51,8 +51,8 @@ services:
# Task Runner config
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MODE=internal
# Enable Insights
- N8N_ENABLED_MODULES=insights
# Disable Insights
- N8N_DISABLED_MODULES=insights
command: worker
volumes:
- ${RUN_DIR}/n8n-worker1:/n8n
@ -86,8 +86,8 @@ services:
# Task Runner config
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MODE=internal
# Enable Insights
- N8N_ENABLED_MODULES=insights
# Disable Insights
- N8N_DISABLED_MODULES=insights
command: worker
volumes:
- ${RUN_DIR}/n8n-worker2:/n8n
@ -122,8 +122,8 @@ services:
# Task Runner config
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MODE=internal
# Enable Insights
- N8N_ENABLED_MODULES=insights
# Disable Insights
- N8N_DISABLED_MODULES=insights
ports:
- 5678:5678
volumes:

View File

@ -51,8 +51,6 @@ services:
# Task Runner config
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MODE=internal
# Disable Insights
- N8N_DISABLED_MODULES=insights
command: worker
volumes:
- ${RUN_DIR}/n8n-worker1:/n8n
@ -86,8 +84,6 @@ services:
# Task Runner config
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MODE=internal
# Disable Insights
- N8N_DISABLED_MODULES=insights
command: worker
volumes:
- ${RUN_DIR}/n8n-worker2:/n8n
@ -122,8 +118,6 @@ services:
# Task Runner config
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MODE=internal
# Disable Insights
- N8N_DISABLED_MODULES=insights
ports:
- 5678:5678
volumes:

View File

@ -1,39 +0,0 @@
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
# Enable Insights
- N8N_ENABLED_MODULES=insights
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

@ -1,15 +0,0 @@
#!/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

@ -12,8 +12,6 @@ services:
environment:
- N8N_DIAGNOSTICS_ENABLED=false
- N8N_USER_FOLDER=/n8n
# Disable Insights
- N8N_DISABLED_MODULES=insights
ports:
- 5678:5678
volumes:

View File

@ -1,44 +0,0 @@
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
# Task Runner config
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MODE=internal
# Enable Insights
- N8N_ENABLED_MODULES=insights
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

@ -1,15 +0,0 @@
#!/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

@ -17,8 +17,6 @@ services:
# Task Runner config
- N8N_RUNNERS_ENABLED=true
- N8N_RUNNERS_MODE=internal
# Disable Insights
- N8N_DISABLED_MODULES=insights
ports:
- 5678:5678
volumes:

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/client-oauth2",
"version": "0.27.0",
"version": "0.28.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
@ -9,7 +9,7 @@
"format": "biome format --write src test",
"format:check": "biome ci src test",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:dev": "jest --watch"

View File

@ -42,8 +42,9 @@ export class ResponseError extends Error {
readonly status: number,
readonly body: unknown,
readonly code = 'ESTATUS',
readonly message = `HTTP status ${status}`,
) {
super(`HTTP status ${status}`);
super(message);
}
}
@ -133,6 +134,11 @@ export class ClientOAuth2 {
return qs.parse(body) as T;
}
throw new Error(`Unsupported content type: ${contentType}`);
throw new ResponseError(
response.status,
body,
undefined,
`Unsupported content type: ${contentType}`,
);
}
}

View File

@ -23,7 +23,7 @@
"build": "tsc -p tsconfig.build.json",
"test": "jest",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"lint:fix": "eslint . --fix",
"format": "biome format --write src test",
"format:check": "biome ci src test"
},

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/config",
"version": "1.47.0",
"version": "1.48.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
@ -9,7 +9,7 @@
"format": "biome format --write src test",
"format:check": "biome ci src test",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:dev": "jest --watch"

View File

@ -0,0 +1,8 @@
import { Config, Env } from '../decorators';
@Config
export class AiConfig {
/** Whether AI features are enabled. */
@Env('N8N_AI_ENABLED')
enabled: boolean = false;
}

View File

@ -20,10 +20,23 @@ export const LOG_SCOPES = [
'workflow-activation',
'ssh-client',
'data-store',
'cron',
'community-nodes',
] as const;
export type LogScope = (typeof LOG_SCOPES)[number];
@Config
export class CronLoggingConfig {
/**
* Interval in minutes to log currently active cron jobs. Set to `0` to disable.
*
* @example `N8N_LOG_CRON_ACTIVE_INTERVAL=30` will log active crons every 30 minutes.
*/
@Env('N8N_LOG_CRON_ACTIVE_INTERVAL')
activeInterval: number = 0;
}
@Config
class FileLoggingConfig {
/**
@ -80,6 +93,9 @@ export class LoggingConfig {
@Nested
file: FileLoggingConfig;
@Nested
cron: CronLoggingConfig;
/**
* Scopes to filter logs by. Nothing is filtered by default.
*

View File

@ -1,4 +1,4 @@
import { Config, Env, Nested } from '../decorators';
import { Config, Env } from '../decorators';
function isStringArray(input: unknown): input is string[] {
return Array.isArray(input) && input.every((item) => typeof item === 'string');
@ -20,33 +20,6 @@ class JsonStringArray extends Array<string> {
}
}
@Config
class CommunityPackagesConfig {
/** Whether to enable community packages */
@Env('N8N_COMMUNITY_PACKAGES_ENABLED')
enabled: boolean = true;
/** NPM registry URL to pull community packages from */
@Env('N8N_COMMUNITY_PACKAGES_REGISTRY')
registry: string = 'https://registry.npmjs.org';
/** Whether to reinstall any missing community packages */
@Env('N8N_REINSTALL_MISSING_PACKAGES')
reinstallMissing: boolean = false;
/** Whether to block installation of not verified packages */
@Env('N8N_UNVERIFIED_PACKAGES_ENABLED')
unverifiedEnabled: boolean = true;
/** Whether to enable and show search suggestion of packages verified by n8n */
@Env('N8N_VERIFIED_PACKAGES_ENABLED')
verifiedEnabled: boolean = true;
/** Whether to load community packages */
@Env('N8N_COMMUNITY_PACKAGES_PREVENT_LOADING')
preventLoading: boolean = false;
}
@Config
export class NodesConfig {
/** Node types to load. Includes all if unspecified. @example '["n8n-nodes-base.hackerNews"]' */
@ -64,7 +37,4 @@ export class NodesConfig {
/** Whether to enable Python execution on the Code node. */
@Env('N8N_PYTHON_ENABLED')
pythonEnabled: boolean = true;
@Nested
communityPackages: CommunityPackagesConfig;
}

View File

@ -0,0 +1,8 @@
import { Config, Env } from '../decorators';
@Config
export class RedisConfig {
/** Prefix for all Redis keys managed by n8n. */
@Env('N8N_REDIS_KEY_PREFIX')
prefix: string = 'n8n';
}

View File

@ -38,4 +38,8 @@ export class SecurityConfig {
*/
@Env('N8N_CONTENT_SECURITY_POLICY_REPORT_ONLY')
contentSecurityPolicyReportOnly: boolean = false;
/** Whether to disable iframe sandboxing for webhooks */
@Env('N8N_INSECURE_DISABLE_WEBHOOK_IFRAME_SANDBOX')
disableIframeSandboxing: boolean = false;
}

View File

@ -1,6 +1,7 @@
import { z } from 'zod';
import { AiAssistantConfig } from './configs/ai-assistant.config';
import { AiConfig } from './configs/ai.config';
import { AuthConfig } from './configs/auth.config';
import { CacheConfig } from './configs/cache.config';
import { CredentialsConfig } from './configs/credentials.config';
@ -21,6 +22,7 @@ import { NodesConfig } from './configs/nodes.config';
import { PartialExecutionsConfig } from './configs/partial-executions.config';
import { PersonalizationConfig } from './configs/personalization.config';
import { PublicApiConfig } from './configs/public-api.config';
import { RedisConfig } from './configs/redis.config';
import { TaskRunnersConfig } from './configs/runners.config';
import { ScalingModeConfig } from './configs/scaling-mode.config';
import { SecurityConfig } from './configs/security.config';
@ -49,6 +51,7 @@ export { MfaConfig } from './configs/mfa.config';
export { HiringBannerConfig } from './configs/hiring-banner.config';
export { PersonalizationConfig } from './configs/personalization.config';
export { NodesConfig } from './configs/nodes.config';
export { CronLoggingConfig } from './configs/logging.config';
const protocolSchema = z.enum(['http', 'https']);
@ -195,4 +198,14 @@ export class GlobalConfig {
/** Public URL where the editor is accessible. Also used for emails sent from n8n. */
@Env('N8N_EDITOR_BASE_URL')
editorBaseUrl: string = '';
/** URLs to external frontend hooks files, separated by semicolons. */
@Env('EXTERNAL_FRONTEND_HOOKS_URLS')
externalFrontendHooksUrls: string = '';
@Nested
redis: RedisConfig;
@Nested
ai: AiConfig;
}

View File

@ -138,14 +138,6 @@ describe('GlobalConfig', () => {
files: [],
},
nodes: {
communityPackages: {
enabled: true,
registry: 'https://registry.npmjs.org',
reinstallMissing: false,
unverifiedEnabled: true,
verifiedEnabled: true,
preventLoading: false,
},
errorTriggerType: 'n8n-nodes-base.errorTrigger',
include: [],
exclude: [],
@ -275,6 +267,9 @@ describe('GlobalConfig', () => {
location: 'logs/n8n.log',
},
scopes: [],
cron: {
activeInterval: 0,
},
},
multiMainSetup: {
enabled: false,
@ -300,6 +295,7 @@ describe('GlobalConfig', () => {
daysAbandonedWorkflow: 90,
contentSecurityPolicy: '{}',
contentSecurityPolicyReportOnly: false,
disableIframeSandboxing: false,
},
executions: {
pruneData: true,
@ -348,6 +344,13 @@ describe('GlobalConfig', () => {
loginLabel: '',
},
},
redis: {
prefix: 'n8n',
},
externalFrontendHooksUrls: '',
ai: {
enabled: false,
},
};
it('should use all default values when no env variables are defined', () => {

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/constants",
"version": "0.9.0",
"version": "0.10.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
@ -9,7 +9,7 @@
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch"
},
"main": "dist/index.js",

View File

@ -0,0 +1,13 @@
# @n8n/create-node
Scaffold a new community n8n node
## Usage
```bash
npm create @n8n/node
# or
pnpm create @n8n/node
# or
yarn create @n8n/node
```

View File

@ -0,0 +1,15 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import path from 'node:path';
const require = createRequire(import.meta.url);
const cliBin = require.resolve('@n8n/node-cli/bin/n8n-node.js');
const result = spawnSync('node', [cliBin, 'create', ...process.argv.slice(2)], {
stdio: 'inherit',
});
process.exit(result.status ?? 1);

View File

@ -0,0 +1,25 @@
{
"private": true,
"type": "module",
"name": "@n8n/create-node",
"version": "0.1.0",
"description": "Official CLI to create new community nodes for n8n",
"bin": {
"create-n8n-node": "./bin/create.js"
},
"files": [
"bin",
"dist"
],
"scripts": {
"publish:dry": "pnpm run build && pnpm pub --dry-run",
"start": "./bin/create.js"
},
"repository": {
"type": "git",
"url": "https://github.com/n8n-io/n8n"
},
"dependencies": {
"@n8n/node-cli": "workspace:*"
}
}

View File

@ -0,0 +1,11 @@
{
"extends": "@n8n/typescript-config/modern/tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"rootDir": "src",
"outDir": "dist",
"types": ["vite/client", "vitest/globals"],
"isolatedModules": true
},
"include": ["src/**/*.ts"]
}

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/db",
"version": "0.15.0",
"version": "0.16.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
@ -9,7 +9,7 @@
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:dev": "jest --watch"

View File

@ -1,5 +1,5 @@
import { Column, Entity, ManyToOne, OneToOne } from '@n8n/typeorm';
import type { IDataObject } from 'n8n-workflow';
import type { IDataObject, JsonObject } from 'n8n-workflow';
import { WithStringId, DateTimeColumn, JsonColumn } from './abstract-entity';
import type { ExecutionEntity } from './execution-entity';
@ -54,4 +54,10 @@ export class TestCaseExecution extends WithStringId {
@JsonColumn({ nullable: true })
metrics: TestCaseRunMetrics;
@JsonColumn({ nullable: true })
inputs: JsonObject | null;
@JsonColumn({ nullable: true })
outputs: JsonObject | null;
}

View File

@ -0,0 +1,11 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
export class AddInputsOutputsToTestCaseExecution1752669793000 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, column } }: MigrationContext) {
await addColumns('test_case_execution', [column('inputs').json, column('outputs').json]);
}
async down({ schemaBuilder: { dropColumns } }: MigrationContext) {
await dropColumns('test_case_execution', ['inputs', 'outputs']);
}
}

View File

@ -88,6 +88,7 @@ import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-
import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTable';
import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn';
import { AddLastActiveAtColumnToUser1750252139166 } from '../common/1750252139166-AddLastActiveAtColumnToUser';
import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752669793000-AddInputsOutputsToTestCaseExecution';
import type { Migration } from '../migration-types';
import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn';
@ -183,4 +184,5 @@ export const mysqlMigrations: Migration[] = [
ClearEvaluation1745322634000,
AddProjectDescriptionColumn1747824239000,
AddLastActiveAtColumnToUser1750252139166,
AddInputsOutputsToTestCaseExecution1752669793000,
];

View File

@ -1,4 +1,5 @@
import { AddMfaColumns1690000000030 } from './../common/1690000000040-AddMfaColumns';
import { AddInputsOutputsToTestCaseExecution1752669793000 } from './../common/1752669793000-AddInputsOutputsToTestCaseExecution';
import { InitialMigration1587669153312 } from './1587669153312-InitialMigration';
import { WebhookModel1589476000887 } from './1589476000887-WebhookModel';
import { CreateIndexStoppedAt1594828256133 } from './1594828256133-CreateIndexStoppedAt';
@ -181,4 +182,5 @@ export const postgresMigrations: Migration[] = [
ClearEvaluation1745322634000,
AddProjectDescriptionColumn1747824239000,
AddLastActiveAtColumnToUser1750252139166,
AddInputsOutputsToTestCaseExecution1752669793000,
];

View File

@ -86,7 +86,9 @@ import { DropRoleTable1745934666077 } from '../common/1745934666077-DropRoleTabl
import { CreateDataStoreTables1747814180618 } from '../common/1747814180618-CreateDataStoreTables';
import { AddProjectDescriptionColumn1747824239000 } from '../common/1747824239000-AddProjectDescriptionColumn';
import { AddLastActiveAtColumnToUser1750252139166 } from '../common/1750252139166-AddLastActiveAtColumnToUser';
import { AddInputsOutputsToTestCaseExecution1752669793000 } from '../common/1752669793000-AddInputsOutputsToTestCaseExecution';
import type { Migration } from '../migration-types';
const sqliteMigrations: Migration[] = [
InitialMigration1588102412422,
WebhookModel1592445003908,
@ -176,6 +178,7 @@ const sqliteMigrations: Migration[] = [
CreateDataStoreTables1747814180618,
AddProjectDescriptionColumn1747824239000,
AddLastActiveAtColumnToUser1750252139166,
AddInputsOutputsToTestCaseExecution1752669793000,
];
export { sqliteMigrations };

View File

@ -922,7 +922,7 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
async getLiveExecutionRowsOnPostgres() {
const tableName = `${this.globalConfig.database.tablePrefix}execution_entity`;
const pgSql = `SELECT n_live_tup as result FROM pg_stat_all_tables WHERE relname = '${tableName}';`;
const pgSql = `SELECT n_live_tup as result FROM pg_stat_all_tables WHERE relname = '${tableName}' and schemaname = '${this.globalConfig.database.postgresdb.schema}';`;
try {
const rows = (await this.query(pgSql)) as Array<{ result: string }>;

View File

@ -22,7 +22,7 @@ export class TestRunRepository extends Repository<TestRun> {
super(TestRun, dataSource.manager);
}
async createTestRun(workflowId: string) {
async createTestRun(workflowId: string): Promise<TestRun> {
const testRun = this.create({
status: 'new',
workflow: {

View File

@ -19,7 +19,9 @@ import type {
FolderWithWorkflowAndSubFolderCount,
ListQuery,
} from '../entities/types-db';
import { buildWorkflowsByNodesQuery } from '../utils/build-workflows-by-nodes-query';
import { isStringArray } from '../utils/is-string-array';
import { TimedQuery } from '../utils/timed-query';
type ResourceType = 'folder' | 'workflow';
@ -371,6 +373,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
});
}
@TimedQuery()
async getMany(workflowIds: string[], options: ListQuery.Options = {}) {
if (workflowIds.length === 0) {
return [];
@ -710,4 +713,22 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
{ parentFolder: toFolderId === PROJECT_ROOT ? null : { id: toFolderId } },
);
}
async findWorkflowsWithNodeType(nodeTypes: string[]) {
if (!nodeTypes?.length) return [];
const qb = this.createQueryBuilder('workflow');
const { whereClause, parameters } = buildWorkflowsByNodesQuery(
nodeTypes,
this.globalConfig.database.type,
);
const workflows: Array<{ id: string; name: string; active: boolean }> = await qb
.select(['workflow.id', 'workflow.name', 'workflow.active'])
.where(whereClause, parameters)
.getMany();
return workflows;
}
}

View File

@ -0,0 +1,48 @@
import { buildWorkflowsByNodesQuery } from '../build-workflows-by-nodes-query';
describe('WorkflowRepository', () => {
describe('filterWorkflowsByNodesConstructWhereClause', () => {
it('should return the correct WHERE clause and parameters for sqlite', () => {
const nodeTypes = ['HTTP Request', 'Set'];
const expectedInQuery =
"FROM json_each(workflow.nodes) WHERE json_extract(json_each.value, '$.type')";
const expectedParameters = {
nodeType0: 'HTTP Request',
nodeType1: 'Set',
nodeTypes,
};
const { whereClause, parameters } = buildWorkflowsByNodesQuery(nodeTypes, 'sqlite');
expect(whereClause).toContain(expectedInQuery);
expect(parameters).toEqual(expectedParameters);
});
it('should return the correct WHERE clause and parameters for postgresdb', () => {
const nodeTypes = ['HTTP Request', 'Set'];
const expectedInQuery = 'FROM jsonb_array_elements(workflow.nodes::jsonb) AS node';
const expectedParameters = { nodeTypes };
const { whereClause, parameters } = buildWorkflowsByNodesQuery(nodeTypes, 'postgresdb');
expect(whereClause).toContain(expectedInQuery);
expect(parameters).toEqual(expectedParameters);
});
it('should return the correct WHERE clause and parameters for mysqldb', () => {
const nodeTypes = ['HTTP Request', 'Set'];
const expectedWhereClause =
"(JSON_SEARCH(JSON_EXTRACT(workflow.nodes, '$[*].type'), 'one', :nodeType0) IS NOT NULL OR JSON_SEARCH(JSON_EXTRACT(workflow.nodes, '$[*].type'), 'one', :nodeType1) IS NOT NULL)";
const expectedParameters = {
nodeType0: 'HTTP Request',
nodeType1: 'Set',
nodeTypes,
};
const { whereClause, parameters } = buildWorkflowsByNodesQuery(nodeTypes, 'mysqldb');
expect(whereClause).toEqual(expectedWhereClause);
expect(parameters).toEqual(expectedParameters);
});
});
});

View File

@ -0,0 +1,56 @@
/**
* Builds the WHERE clause and parameters for a query to find workflows by node types
*/
export function buildWorkflowsByNodesQuery(
nodeTypes: string[],
dbType: 'postgresdb' | 'mysqldb' | 'mariadb' | 'sqlite',
) {
let whereClause: string;
const parameters: Record<string, string | string[]> = { nodeTypes };
switch (dbType) {
case 'postgresdb':
whereClause = `EXISTS (
SELECT 1
FROM jsonb_array_elements(workflow.nodes::jsonb) AS node
WHERE node->>'type' = ANY(:nodeTypes)
)`;
break;
case 'mysqldb':
case 'mariadb': {
const conditions = nodeTypes
.map(
(_, i) =>
`JSON_SEARCH(JSON_EXTRACT(workflow.nodes, '$[*].type'), 'one', :nodeType${i}) IS NOT NULL`,
)
.join(' OR ');
whereClause = `(${conditions})`;
nodeTypes.forEach((nodeType, index) => {
parameters[`nodeType${index}`] = nodeType;
});
break;
}
case 'sqlite': {
const conditions = nodeTypes
.map(
(_, i) =>
`EXISTS (SELECT 1 FROM json_each(workflow.nodes) WHERE json_extract(json_each.value, '$.type') = :nodeType${i})`,
)
.join(' OR ');
whereClause = `(${conditions})`;
nodeTypes.forEach((nodeType, index) => {
parameters[`nodeType${index}`] = nodeType;
});
break;
}
default:
throw new Error('Unsupported database type');
}
return { whereClause, parameters };
}

View File

@ -0,0 +1,26 @@
import { Logger } from '@n8n/backend-common';
import { Timed } from '@n8n/decorators';
import { Container } from '@n8n/di';
/**
* Decorator that warns when database queries exceed a duration threshold.
*
* For options, see `@n8n/decorators/src/timed.ts`.
*
* @example
* ```ts
* @Service()
* class UserRepository {
* @TimedQuery()
* async findUsers() {
* // will log warning if execution takes > 100ms
* }
*
* @TimedQuery({ threshold: 50, logArgs: true })
* async findUserById(id: string) {
* // will log warning if execution takes >50ms, including args
* }
* }
* ```
*/
export const TimedQuery = Timed(Container.get(Logger), 'Slow database query');

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/decorators",
"version": "0.14.0",
"version": "0.15.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
@ -9,7 +9,7 @@
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:dev": "jest --watch"

View File

@ -9,3 +9,4 @@ export * from './pubsub';
export { Redactable } from './redactable';
export * from './shutdown';
export * from './module/module-metadata';
export { Timed, TimedOptions } from './timed';

View File

@ -0,0 +1,42 @@
export interface TimedOptions {
/** Duration (in ms) above which to log a warning. Defaults to `100`. */
threshold?: number;
/** Whether to include method parameters in the log. Defaults to `false`. */
logArgs?: boolean;
}
interface Logger {
warn(message: string, meta?: object): void;
}
/**
* Factory to create decorators to warn when method calls exceed a duration threshold.
*/
export const Timed =
(logger: Logger, msg = 'Slow method call') =>
(options: TimedOptions = {}): MethodDecorator =>
(_target, propertyKey, descriptor: PropertyDescriptor) => {
const originalMethod = descriptor.value as (...args: unknown[]) => unknown;
const thresholdMs = options.threshold ?? 100;
const logArgs = options.logArgs ?? false;
descriptor.value = async function (...args: unknown[]) {
const methodName = `${this.constructor.name}.${String(propertyKey)}`;
const start = performance.now();
const result = await originalMethod.apply(this, args);
const durationMs = performance.now() - start;
if (durationMs > thresholdMs) {
logger.warn(msg, {
method: methodName,
durationMs: Math.round(durationMs),
thresholdMs,
params: logArgs ? args : '[hidden]',
});
}
return result;
};
return descriptor;
};

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/di",
"version": "0.8.0",
"version": "0.9.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
@ -9,7 +9,7 @@
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:dev": "jest --watch"

View File

@ -1,6 +1,6 @@
{
"name": "@n8n/errors",
"version": "0.2.0",
"version": "0.3.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
@ -9,10 +9,10 @@
"format": "biome format --write .",
"format:check": "biome ci .",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"lint:fix": "eslint . --fix",
"watch": "tsc -p tsconfig.build.json --watch",
"test": "jest",
"test:dev": "jest --watch"
"test": "echo \"WARNING: no test specified\" && exit 0",
"test:dev": "echo \"WARNING: no test specified\" && exit 0"
},
"main": "dist/index.js",
"module": "src/index.ts",
@ -21,6 +21,10 @@
"dist/**/*"
],
"devDependencies": {
"@n8n/typescript-config": "workspace:*"
"@n8n/typescript-config": "workspace:*",
"@sentry/node": "catalog:"
},
"dependencies": {
"callsites": "catalog:"
}
}

View File

@ -1,7 +1,7 @@
import type { Event } from '@sentry/node';
import callsites from 'callsites';
import type { ErrorLevel, ReportingOptions } from './error.types';
import type { ErrorLevel, ReportingOptions } from './types';
/**
* @deprecated Use `UserError`, `OperationalError` or `UnexpectedError` instead.
@ -26,9 +26,11 @@ export class ApplicationError extends Error {
try {
const filePath = callsites()[2].getFileName() ?? '';
// eslint-disable-next-line no-useless-escape
const match = /packages\/([^\/]+)\//.exec(filePath)?.[1];
if (match) this.tags.packageName = match;
// eslint-disable-next-line no-empty
} catch {}
}
}

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