Merge remote-tracking branch 'upstream/master' into node-azure-cosmosdb

This commit is contained in:
Adina Totorean 2025-02-10 14:09:48 +02:00
commit e779822043
2154 changed files with 131066 additions and 18579 deletions

View file

@ -29,11 +29,14 @@ jobs:
tenant-id: ${{ secrets.BENCHMARK_ARM_TENANT_ID }}
subscription-id: ${{ secrets.BENCHMARK_ARM_SUBSCRIPTION_ID }}
- run: corepack enable
- uses: actions/setup-node@v4.0.2
- uses: actions/setup-node@v4.2.0
with:
node-version: 20.x
cache: 'pnpm'
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.31
corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile

View file

@ -48,11 +48,14 @@ jobs:
with:
terraform_version: '1.8.5'
- run: corepack enable
- uses: actions/setup-node@v4.0.2
- uses: actions/setup-node@v4.2.0
with:
node-version: 20.x
cache: pnpm
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.31
corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile

View file

@ -16,11 +16,14 @@ jobs:
steps:
- uses: actions/checkout@v4.1.1
- run: corepack enable
- uses: actions/setup-node@v4.0.2
- uses: actions/setup-node@v4.2.0
with:
node-version: 20.x
cache: 'pnpm'
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.31
corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile

View file

@ -17,11 +17,14 @@ jobs:
- name: Check out branch
uses: actions/checkout@v4.1.1
- run: corepack enable
- uses: actions/setup-node@v4.0.2
- uses: actions/setup-node@v4.2.0
with:
node-version: 20.x
cache: 'pnpm'
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.31
corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile

View file

@ -8,13 +8,14 @@ on:
types: [submitted]
concurrency:
group: chromatic-${{ github.event.pull_request.number || github.ref }}
group: chromatic-${{ github.event.pull_request.number || github.ref }}-${{github.event.review.state}}
cancel-in-progress: true
jobs:
get-metadata:
name: Get Metadata
runs-on: ubuntu-latest
if: github.event.review.state == 'approved'
steps:
- name: Check out current commit
uses: actions/checkout@v4
@ -54,11 +55,16 @@ jobs:
- uses: actions/checkout@v4.1.1
with:
fetch-depth: 0
- run: corepack enable
- uses: actions/setup-node@v4.0.2
- uses: actions/setup-node@v4.2.0
with:
node-version: 20.x
cache: 'pnpm'
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.31
corepack enable
- run: pnpm install --frozen-lockfile
- name: Publish to Chromatic

View file

@ -7,30 +7,35 @@ on:
jobs:
install-and-build:
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2204
env:
NODE_OPTIONS: '--max-old-space-size=4096'
timeout-minutes: 10
steps:
- uses: actions/checkout@v4.1.1
- run: corepack enable
- uses: actions/setup-node@v4.0.2
- uses: useblacksmith/setup-node@v5
with:
node-version: 20.x
cache: pnpm
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.31
corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup build cache
uses: rharkor/caching-for-turbo@v1.5
uses: useblacksmith/caching-for-turbo@v1
- name: Build
run: pnpm build
- name: Cache build artifacts
uses: actions/cache/save@v4.0.0
uses: useblacksmith/cache/save@v5
with:
path: ./packages/**/dist
key: ${{ github.sha }}-base:build
@ -48,6 +53,7 @@ jobs:
cacheKey: ${{ github.sha }}-base:build
collectCoverage: ${{ matrix.node-version == '20.x' }}
ignoreTurboCache: ${{ matrix.node-version == '20.x' }}
skipFrontendTests: ${{ matrix.node-version != '20.x' }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

View file

@ -23,11 +23,16 @@ jobs:
if: github.event_name != 'pull_request_review' || startsWith(github.event.pull_request.base.ref, 'release/')
steps:
- uses: actions/checkout@v4.1.1
- run: corepack enable
- uses: actions/setup-node@v4.0.2
- uses: actions/setup-node@v4.2.0
with:
node-version: 20.x
cache: 'pnpm'
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.31
corepack enable
- run: pnpm install --frozen-lockfile
- name: Setup build cache
@ -52,11 +57,16 @@ jobs:
DB_SQLITE_POOL_SIZE: 4
steps:
- uses: actions/checkout@v4.1.1
- run: corepack enable
- uses: actions/setup-node@v4.0.2
- uses: actions/setup-node@v4.2.0
with:
node-version: 20.x
cache: 'pnpm'
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.31
corepack enable
- run: pnpm install --frozen-lockfile
- name: Setup build cache
@ -81,11 +91,16 @@ jobs:
DB_MYSQLDB_PASSWORD: password
steps:
- uses: actions/checkout@v4.1.1
- run: corepack enable
- uses: actions/setup-node@v4.0.2
- uses: actions/setup-node@v4.2.0
with:
node-version: 20.x
cache: 'pnpm'
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.31
corepack enable
- run: pnpm install --frozen-lockfile
- name: Setup build cache
@ -118,11 +133,16 @@ jobs:
DB_POSTGRESDB_POOL_SIZE: 1 # Detect connection pooling deadlocks
steps:
- uses: actions/checkout@v4.1.1
- run: corepack enable
- uses: actions/setup-node@v4.0.2
- uses: actions/setup-node@v4.2.0
with:
node-version: 20.x
cache: 'pnpm'
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.31
corepack enable
- run: pnpm install --frozen-lockfile
- name: Setup build cache

View file

@ -9,23 +9,28 @@ on:
jobs:
install-and-build:
name: Install & Build
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2204
env:
NODE_OPTIONS: '--max-old-space-size=4096'
steps:
- uses: actions/checkout@v4.1.1
with:
ref: refs/pull/${{ github.event.pull_request.number }}/merge
- run: corepack enable
- uses: actions/setup-node@v4.0.2
- uses: useblacksmith/setup-node@v5
with:
node-version: 20.x
cache: pnpm
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.31
corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup build cache
uses: rharkor/caching-for-turbo@v1.5
uses: useblacksmith/caching-for-turbo@v1
- name: Build
run: pnpm build
@ -37,7 +42,7 @@ jobs:
run: pnpm typecheck
- name: Cache build artifacts
uses: actions/cache/save@v4.0.0
uses: useblacksmith/cache/save@v5
with:
path: ./packages/**/dist
key: ${{ github.sha }}-base:build

View file

@ -20,26 +20,28 @@ jobs:
- uses: actions/checkout@v4.1.1
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
uses: docker/setup-qemu-action@v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
uses: docker/setup-buildx-action@v3.8.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3.3.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build
uses: docker/build-push-action@v5.1.0
uses: docker/build-push-action@v6.11.0
env:
DOCKER_BUILD_SUMMARY: false
with:
context: .
file: ./docker/images/n8n-base/Dockerfile

View file

@ -19,20 +19,22 @@ jobs:
- uses: actions/checkout@v4.1.1
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
uses: docker/setup-qemu-action@v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
uses: docker/setup-buildx-action@v3.8.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build
uses: docker/build-push-action@v5.1.0
uses: docker/build-push-action@v6.11.0
env:
DOCKER_BUILD_SUMMARY: false
with:
context: .
file: ./packages/@n8n/benchmark/Dockerfile

View file

@ -0,0 +1,83 @@
name: Docker Custom Image CI
run-name: Build ${{ inputs.branch }} - ${{ inputs.user }}
on:
workflow_dispatch:
inputs:
branch:
description: 'GitHub branch to create image off.'
required: true
tag:
description: 'Name of the docker tag to create.'
required: true
merge-master:
description: 'Merge with master.'
type: boolean
required: true
default: false
user:
description: ''
required: false
default: 'none'
start-url:
description: 'URL to call after workflow is kicked off.'
required: false
default: ''
success-url:
description: 'URL to call after Docker Image got built successfully.'
required: false
default: ''
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Call Start URL - optionally
if: ${{ github.event.inputs.start-url != '' }}
run: curl -v -X POST -d 'url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' ${{github.event.inputs.start-url}} || echo ""
shell: bash
- name: Checkout
uses: actions/checkout@v4.1.1
with:
ref: ${{ github.event.inputs.branch }}
- name: Merge Master - optionally
if: github.event.inputs.merge-master
run: git remote add upstream https://github.com/n8n-io/n8n.git -f; git merge upstream/master --allow-unrelated-histories || echo ""
shell: bash
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.8.0
- name: Login to GHCR
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image to GHCR
uses: docker/build-push-action@v6.11.0
env:
DOCKER_BUILD_SUMMARY: false
with:
context: .
file: ./docker/images/n8n-custom/Dockerfile
build-args: |
N8N_RELEASE_TYPE=development
platforms: linux/amd64
provenance: false
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ghcr.io/${{ github.repository_owner }}/n8n:${{ inputs.tag }}
- name: Call Success URL - optionally
if: ${{ github.event.inputs.success-url != '' }}
run: curl -v ${{github.event.inputs.success-url}} || echo ""
shell: bash

View file

@ -1,74 +1,40 @@
name: Docker Nightly Image CI
run-name: Build ${{ inputs.branch }} - ${{ inputs.user }}
on:
schedule:
- cron: '0 1 * * *'
- cron: '0 0 * * *'
workflow_dispatch:
inputs:
branch:
description: 'GitHub branch to create image off.'
required: true
default: 'master'
tag:
description: 'Name of the docker tag to create.'
required: true
default: 'nightly'
merge-master:
description: 'Merge with master.'
type: boolean
required: true
default: false
user:
description: ''
required: false
default: 'schedule'
start-url:
description: 'URL to call after workflow is kicked off.'
required: false
default: ''
success-url:
description: 'URL to call after Docker Image got built successfully.'
required: false
default: ''
env:
N8N_TAG: ${{ inputs.tag || 'nightly' }}
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Call Start URL - optionally
run: |
[[ "${{github.event.inputs.start-url}}" != "" ]] && curl -v -X POST -d 'url=${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' ${{github.event.inputs.start-url}} || echo ""
shell: bash
- name: Checkout
uses: actions/checkout@v4.1.1
with:
ref: ${{ github.event.inputs.branch || 'master' }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
uses: docker/setup-qemu-action@v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
uses: docker/setup-buildx-action@v3.8.0
- name: Login to GHCR
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3.3.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Merge Master - optionally
run: |
[[ "${{github.event.inputs.merge-master}}" == "true" ]] && git remote add upstream https://github.com/n8n-io/n8n.git -f; git merge upstream/master --allow-unrelated-histories || echo ""
shell: bash
- name: Build and push to DockerHub
uses: docker/build-push-action@v5.1.0
- name: Build and push image to GHCR and DockerHub
uses: docker/build-push-action@v6.11.0
env:
DOCKER_BUILD_SUMMARY: false
with:
context: .
file: ./docker/images/n8n-custom/Dockerfile
@ -79,24 +45,6 @@ jobs:
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ secrets.DOCKER_USERNAME }}/n8n:${{ env.N8N_TAG }}
- name: Login to GitHub Container Registry
if: env.N8N_TAG == 'nightly'
uses: docker/login-action@v3.0.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push image to GHCR
if: env.N8N_TAG == 'nightly'
run: |
docker buildx imagetools create \
--tag ghcr.io/${{ github.repository_owner }}/n8n:nightly \
tags: |
ghcr.io/${{ github.repository_owner }}/n8n:nightly
${{ secrets.DOCKER_USERNAME }}/n8n:nightly
- name: Call Success URL - optionally
run: |
[[ "${{github.event.inputs.success-url}}" != "" ]] && curl -v ${{github.event.inputs.success-url}} || echo ""
shell: bash

View file

@ -41,11 +41,6 @@ on:
description: 'PR number to run tests for.'
required: false
type: number
node_view_version:
description: 'Node View version to run tests with.'
required: false
default: '1'
type: string
secrets:
CYPRESS_RECORD_KEY:
description: 'Cypress record key.'
@ -165,7 +160,7 @@ jobs:
spec: '${{ inputs.spec }}'
env:
NODE_OPTIONS: --dns-result-order=ipv4first
CYPRESS_NODE_VIEW_VERSION: ${{ inputs.node_view_version }}
CYPRESS_NODE_VIEW_VERSION: 2
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
E2E_TESTS: true

View file

@ -5,13 +5,14 @@ on:
types: [submitted]
concurrency:
group: e2e-${{ github.event.pull_request.number || github.ref }}
group: e2e-${{ github.event.pull_request.number || github.ref }}-${{github.event.review.state}}
cancel-in-progress: true
jobs:
get-metadata:
name: Get Metadata
runs-on: ubuntu-latest
if: github.event.review.state == 'approved'
steps:
- name: Check out current commit
uses: actions/checkout@v4

View file

@ -27,11 +27,6 @@ on:
description: 'URL to call after workflow is done.'
required: false
default: ''
node_view_version:
description: 'Node View version to run tests with.'
required: false
default: '1'
type: string
jobs:
calls-start-url:
@ -51,7 +46,6 @@ jobs:
branch: ${{ github.event.inputs.branch || 'master' }}
user: ${{ github.event.inputs.user || 'PR User' }}
spec: ${{ github.event.inputs.spec || 'e2e/*' }}
node_view_version: ${{ github.event.inputs.node_view_version || '1' }}
secrets:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

View file

@ -17,23 +17,28 @@ on:
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2204
env:
NODE_OPTIONS: '--max-old-space-size=4096'
steps:
- uses: actions/checkout@v4.1.1
with:
ref: ${{ inputs.ref }}
- run: corepack enable
- uses: actions/setup-node@v4.0.2
- uses: useblacksmith/setup-node@v5
with:
node-version: 20.x
cache: pnpm
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.31
corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup build cache
uses: rharkor/caching-for-turbo@v1.5
uses: useblacksmith/caching-for-turbo@v1
- name: Build
if: ${{ inputs.cacheKey == '' }}
@ -41,10 +46,11 @@ jobs:
- name: Restore cached build artifacts
if: ${{ inputs.cacheKey != '' }}
uses: actions/cache/restore@v4.0.0
uses: useblacksmith/cache/restore@v5
with:
path: ./packages/**/dist
key: ${{ inputs.cacheKey }}
fail-on-cache-miss: true
- name: Lint Backend
run: pnpm lint:backend

View file

@ -35,13 +35,17 @@ jobs:
fetch-depth: 0
ref: ${{ github.event.inputs.base-branch }}
- run: corepack enable
- uses: actions/setup-node@v4.0.2
- uses: actions/setup-node@v4.2.0
with:
node-version: 20.x
- run: npm install --prefix=.github/scripts --no-package-lock
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.31
corepack enable
- name: Bump package versions
run: |
echo "NEXT_RELEASE=$(node .github/scripts/bump-versions.mjs)" >> $GITHUB_ENV

View file

@ -25,11 +25,15 @@ jobs:
with:
fetch-depth: 0
- run: corepack enable
- uses: actions/setup-node@v4.0.2
- uses: actions/setup-node@v4.2.0
with:
node-version: 20.x
cache: 'pnpm'
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.31
corepack enable
- run: pnpm install --frozen-lockfile
- name: Set release version in env
@ -73,26 +77,28 @@ jobs:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.0.0
uses: docker/setup-qemu-action@v3.3.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
uses: docker/setup-buildx-action@v3.8.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
uses: docker/login-action@v3.0.0
uses: docker/login-action@v3.3.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build
uses: docker/build-push-action@v5.1.0
uses: docker/build-push-action@v6.11.0
env:
DOCKER_BUILD_SUMMARY: false
with:
context: ./docker/images/n8n
build-args: |
@ -159,6 +165,14 @@ jobs:
version: ${{ needs.publish-to-npm.outputs.release }}
sourcemaps: packages/cli/dist packages/core/dist packages/nodes-base/dist packages/@n8n/n8n-nodes-langchain/dist
- name: Create a task runner release
uses: getsentry/action-release@v1.7.0
continue-on-error: true
with:
projects: ${{ secrets.SENTRY_TASK_RUNNER_PROJECT }}
version: ${{ needs.publish-to-npm.outputs.release }}
sourcemaps: packages/core/dist packages/workflow/dist packages/@n8n/task-runner/dist
trigger-release-note:
name: Trigger a release note
needs: [publish-to-npm, create-github-release]

View file

@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/setup-node@v4.0.2
- uses: actions/setup-node@v4.2.0
with:
node-version: 20.x
- run: |
@ -34,7 +34,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: docker/login-action@v3.0.0
- uses: docker/login-action@v3.3.0
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
@ -46,7 +46,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: docker/login-action@v3.0.0
- uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}

View file

@ -22,11 +22,16 @@ jobs:
!contains(github.event.pull_request.labels.*.name, 'community')
steps:
- uses: actions/checkout@v4.1.1
- run: corepack enable
- uses: actions/setup-node@v4.0.2
- uses: actions/setup-node@v4.2.0
with:
node-version: 20.x
cache: 'pnpm'
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.31
corepack enable
- run: pnpm install --frozen-lockfile
- name: Setup build cache
@ -48,11 +53,16 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v4.1.1
- run: corepack enable
- uses: actions/setup-node@v4.0.2
- uses: actions/setup-node@v4.2.0
with:
node-version: 20.x
cache: 'pnpm'
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.31
corepack enable
- run: pnpm install --frozen-lockfile
- name: Setup build cache

View file

@ -12,6 +12,11 @@ on:
description: 'PR number to run tests for.'
required: false
type: number
skipFrontendTests:
description: 'Skip Frontend tests'
required: false
default: false
type: boolean
jobs:
prepare:
@ -37,3 +42,4 @@ jobs:
uses: ./.github/workflows/units-tests-reusable.yml
with:
ref: ${{ needs.prepare.outputs.branch }}
skipFrontendTests: ${{ inputs.skipFrontendTests }}

View file

@ -26,6 +26,10 @@ on:
required: false
default: false
type: boolean
skipFrontendTests:
required: false
default: false
type: boolean
secrets:
CODECOV_TOKEN:
description: 'Codecov upload token.'
@ -34,7 +38,7 @@ on:
jobs:
unit-test:
name: Unit tests
runs-on: ubuntu-latest
runs-on: blacksmith-2vcpu-ubuntu-2204
env:
TURBO_FORCE: ${{ inputs.ignoreTurboCache }}
COVERAGE_ENABLED: ${{ inputs.collectCoverage }}
@ -43,18 +47,21 @@ jobs:
with:
ref: ${{ inputs.ref }}
- run: corepack enable
- name: Use Node.js ${{ inputs.nodeVersion }}
uses: actions/setup-node@v4.0.2
uses: useblacksmith/setup-node@v5
with:
node-version: ${{ inputs.nodeVersion }}
cache: pnpm
- name: Setup corepack and pnpm
run: |
npm i -g corepack@0.31
corepack enable
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Setup build cache
uses: rharkor/caching-for-turbo@v1.5
uses: useblacksmith/caching-for-turbo@v1
- name: Build
if: ${{ inputs.cacheKey == '' }}
@ -62,10 +69,11 @@ jobs:
- name: Restore cached build artifacts
if: ${{ inputs.cacheKey != '' }}
uses: actions/cache/restore@v4.0.0
uses: useblacksmith/cache/restore@v5
with:
path: ./packages/**/dist
key: ${{ inputs.cacheKey }}
fail-on-cache-miss: true
- name: Test Backend
run: pnpm test:backend
@ -74,6 +82,7 @@ jobs:
run: pnpm test:nodes
- name: Test Frontend
if: ${{ !inputs.skipFrontendTests }}
run: pnpm test:frontend
- name: Upload coverage to Codecov

View file

@ -1,9 +1,199 @@
## [1.74.1](https://github.com/n8n-io/n8n/compare/n8n@1.74.0...n8n@1.74.1) (2025-01-09)
# [1.78.0](https://github.com/n8n-io/n8n/compare/n8n@1.77.0...n8n@1.78.0) (2025-02-06)
### Bug Fixes
* **editor:** Fix parameter input validation ([#12532](https://github.com/n8n-io/n8n/issues/12532)) ([6f757f1](https://github.com/n8n-io/n8n/commit/6f757f10bd9102394d2a0b6bbc795f90444f66d2))
* **AI Agent Node:** Ignore SSL errors option for SQLAgent ([#13052](https://github.com/n8n-io/n8n/issues/13052)) ([a90529f](https://github.com/n8n-io/n8n/commit/a90529fd51ca88bc9640d24490dbeb2023c98e30))
* **Code Node:** Do not validate code within comments ([#12938](https://github.com/n8n-io/n8n/issues/12938)) ([cdfa225](https://github.com/n8n-io/n8n/commit/cdfa22593b69cf647c2a798d6571a9bbbd11c1b2))
* **core:** "Respond to Webhook" should work with workflows with waiting nodes ([#12806](https://github.com/n8n-io/n8n/issues/12806)) ([e8635f2](https://github.com/n8n-io/n8n/commit/e8635f257433748f4d7d2c4b0ae794de6bff5b28))
* **core:** Do not emit `workflow-post-execute` event for waiting executions ([#13065](https://github.com/n8n-io/n8n/issues/13065)) ([1593b6c](https://github.com/n8n-io/n8n/commit/1593b6cb4112ab2a85ca93c4eaec7d5f088895b1))
* **core:** Do not enable strict type validation by default for resource mapper ([#13037](https://github.com/n8n-io/n8n/issues/13037)) ([fdcff90](https://github.com/n8n-io/n8n/commit/fdcff9082b97314f8b04579ab6fa81c724916320))
* **core:** Fix empty node execution stack ([#12945](https://github.com/n8n-io/n8n/issues/12945)) ([7031569](https://github.com/n8n-io/n8n/commit/7031569a028bcc85558fcb614f8143d68a7f81f0))
* **core:** Only use new resource mapper type validation when it is enabled ([#13099](https://github.com/n8n-io/n8n/issues/13099)) ([a37c8e8](https://github.com/n8n-io/n8n/commit/a37c8e8fb86aaa3244ac13500ffa0e7c0d809a6f))
* **editor:** Actually enforce the version and don't break for old values in local storage ([#13025](https://github.com/n8n-io/n8n/issues/13025)) ([884a7e2](https://github.com/n8n-io/n8n/commit/884a7e23f84258756d8dcdd2dfe933bdedf61adc))
* **editor:** Add telemetry to source control feature ([#13016](https://github.com/n8n-io/n8n/issues/13016)) ([18eaa54](https://github.com/n8n-io/n8n/commit/18eaa5423dfc9348374c2cff4ae0e6f152268fbb))
* **editor:** Allow switch to `Fixed` for boolean and number parameters with invalid expressions ([#12948](https://github.com/n8n-io/n8n/issues/12948)) ([118be24](https://github.com/n8n-io/n8n/commit/118be24d25f001525ced03d9426a6129fa5a2053))
* **editor:** Allow to re-open sub-connection node creator if already active ([#13041](https://github.com/n8n-io/n8n/issues/13041)) ([16d59e9](https://github.com/n8n-io/n8n/commit/16d59e98edc427bf68edbce4cd2174a44d6dcfb1))
* **editor:** Code node overwrites code when switching nodes after edits ([#13078](https://github.com/n8n-io/n8n/issues/13078)) ([00e3ebc](https://github.com/n8n-io/n8n/commit/00e3ebc9e2e0b8cc2d88b678c3a2a21602dac010))
* **editor:** Fix execution running status listener for chat messages ([#12951](https://github.com/n8n-io/n8n/issues/12951)) ([4d55a29](https://github.com/n8n-io/n8n/commit/4d55a294600dc2c86f6f7019da923b66a4b9de7e))
* **editor:** Fix position of connector buttons when the line is straight ([#13034](https://github.com/n8n-io/n8n/issues/13034)) ([3a908ac](https://github.com/n8n-io/n8n/commit/3a908aca17f0bc1cf5fb5eb8813cc94f27f0bcdf))
* **editor:** Fix showing and hiding canvas edge toolbar when hovering ([#13009](https://github.com/n8n-io/n8n/issues/13009)) ([ac7bc4f](https://github.com/n8n-io/n8n/commit/ac7bc4f1911f913233eeeae5d229432fdff332c4))
* **editor:** Make AI transform node read only in executions view ([#12970](https://github.com/n8n-io/n8n/issues/12970)) ([ce1deb8](https://github.com/n8n-io/n8n/commit/ce1deb8aea528eef996fc774d0fff1dc61df5843))
* **editor:** Prevent infinite loop in expressions crashing the browser ([#12732](https://github.com/n8n-io/n8n/issues/12732)) ([8c2dbcf](https://github.com/n8n-io/n8n/commit/8c2dbcfeced70a0a84137773269cc6db2928d174))
* **editor:** Refine push modal layout ([#12886](https://github.com/n8n-io/n8n/issues/12886)) ([212a5bf](https://github.com/n8n-io/n8n/commit/212a5bf23eb11cc3296e7a8d002a4b7727d5193c))
* **editor:** SchemaView renders duplicate structures properly ([#12943](https://github.com/n8n-io/n8n/issues/12943)) ([0d8a544](https://github.com/n8n-io/n8n/commit/0d8a544975f72724db931778d7e3ace8a12b6cfc))
* **editor:** Update node issues when opening execution ([#12972](https://github.com/n8n-io/n8n/issues/12972)) ([1a91523](https://github.com/n8n-io/n8n/commit/1a915239c6571d7744023c6df6242dabe97c912e))
* **editor:** Use correct connection index when connecting adjancent nodes after deleting a node ([#12973](https://github.com/n8n-io/n8n/issues/12973)) ([c7a15d5](https://github.com/n8n-io/n8n/commit/c7a15d5980d181a865f8e2ec6a5f70d0681dcf56))
* **GitHub Node:** Don't truncate filenames retrieved from GitHub ([#12923](https://github.com/n8n-io/n8n/issues/12923)) ([7e18447](https://github.com/n8n-io/n8n/commit/7e1844757fe0d544e8881d229d16af95ed53fb21))
* **Google Cloud Firestore Node:** Fix potential prototype pollution vulnerability ([#13035](https://github.com/n8n-io/n8n/issues/13035)) ([f150f79](https://github.com/n8n-io/n8n/commit/f150f79ad6c7d43e036688b1de8d6c2c8140aca9))
* Increment runIndex in WorkflowToolV2 tool executions to avoid reusing out of date inputs ([#13008](https://github.com/n8n-io/n8n/issues/13008)) ([cc907fb](https://github.com/n8n-io/n8n/commit/cc907fbca9aa00fe07dd54a2fcac8983f2321ad1))
* Sync partial execution version of FE and BE, also allow enforcing a specific version ([#12840](https://github.com/n8n-io/n8n/issues/12840)) ([a155043](https://github.com/n8n-io/n8n/commit/a15504329bac582225185705566297d9cc27bf73))
* **Wise Node:** Use ISO formatting for timestamps ([#10288](https://github.com/n8n-io/n8n/issues/10288)) ([1a2d39a](https://github.com/n8n-io/n8n/commit/1a2d39a158c9a61bdaf11124b09ae70de65ebbf1))
### Features
* Add reusable frontend `composables` package ([#13077](https://github.com/n8n-io/n8n/issues/13077)) ([ef87da4](https://github.com/n8n-io/n8n/commit/ef87da4c193a08e089e48044906a4f5ce9959a22))
* Add support for client credentials with Azure Log monitor ([#13038](https://github.com/n8n-io/n8n/issues/13038)) ([2c2d631](https://github.com/n8n-io/n8n/commit/2c2d63157b7866f1a68cc45c5823e29570ccff77))
* Allow multi API creation via the UI ([#12845](https://github.com/n8n-io/n8n/issues/12845)) ([ad3250c](https://github.com/n8n-io/n8n/commit/ad3250ceb0df84379917e684d54d4100e3bf44f5))
* Allow setting API keys expiration ([#12954](https://github.com/n8n-io/n8n/issues/12954)) ([9bcbc2c](https://github.com/n8n-io/n8n/commit/9bcbc2c2ccbb88537e9b7554c92b631118d870f1))
* **core:** Add sorting to GET `/workflows` endpoint ([#13029](https://github.com/n8n-io/n8n/issues/13029)) ([b60011a](https://github.com/n8n-io/n8n/commit/b60011a1808d47f32ab84e685dba0e915e82df8f))
* **core:** Enable usage as a tool for more nodes ([#12930](https://github.com/n8n-io/n8n/issues/12930)) ([9deb759](https://github.com/n8n-io/n8n/commit/9deb75916e4eb63b899ba79b40cbd24b69a752db))
* **core:** Handle Declarative nodes more like regular nodes ([#13007](https://github.com/n8n-io/n8n/issues/13007)) ([a65a9e6](https://github.com/n8n-io/n8n/commit/a65a9e631b13bbe70ad64727fb1109ae7cd014eb))
* **Discord Node:** New sendAndWait operation ([#12894](https://github.com/n8n-io/n8n/issues/12894)) ([d47bfdd](https://github.com/n8n-io/n8n/commit/d47bfddd656367454b51da39cf87dbfb2bd59eb2))
* **editor:** Display schema preview for unexecuted nodes ([#12901](https://github.com/n8n-io/n8n/issues/12901)) ([0063bbb](https://github.com/n8n-io/n8n/commit/0063bbb30b45b3af92aff4c0f76b905d50a71a2d))
* **editor:** Easy $fromAI Button for AI Tools ([#12587](https://github.com/n8n-io/n8n/issues/12587)) ([2177376](https://github.com/n8n-io/n8n/commit/21773764d37c37a6464a3885d3fa548a5feb4fd8))
* **editor:** Show fixed collection parameter issues in UI ([#12899](https://github.com/n8n-io/n8n/issues/12899)) ([12d686c](https://github.com/n8n-io/n8n/commit/12d686ce52694f4c0b88f92a744451c1b0c66dec))
* **Facebook Graph API Node:** Update node to support API v22.0 ([#13024](https://github.com/n8n-io/n8n/issues/13024)) ([0bc0fc6](https://github.com/n8n-io/n8n/commit/0bc0fc6c1226688c29bf5f8f0ba7e8f244e16fbc))
* **HTTP Request Tool Node:** Relax binary data detection ([#13048](https://github.com/n8n-io/n8n/issues/13048)) ([b67a003](https://github.com/n8n-io/n8n/commit/b67a003e0b154d4e8c04392bec1c7b28171b5908))
* Human in the loop section ([#12883](https://github.com/n8n-io/n8n/issues/12883)) ([9590e5d](https://github.com/n8n-io/n8n/commit/9590e5d58b8964de9ce901bf07b537926d18b6b7))
* **n8n Form Node:** Add Hidden Fields ([#12803](https://github.com/n8n-io/n8n/issues/12803)) ([0da1114](https://github.com/n8n-io/n8n/commit/0da1114981978e371b216bdabc0c3bbdceeefa09))
* **n8n Form Node:** Respond with Text ([#12979](https://github.com/n8n-io/n8n/issues/12979)) ([182fc15](https://github.com/n8n-io/n8n/commit/182fc150bec62e9a5e2801d6c403e4a6bd35f728))
* **OpenAI Chat Model Node, OpenAI Node:** Include o3 models in model selection ([#13005](https://github.com/n8n-io/n8n/issues/13005)) ([37d152c](https://github.com/n8n-io/n8n/commit/37d152c148cafbe493c22e07f5d55ff24fcb0ca4))
* **Summarize Node:** Preserves original field data type ([#13069](https://github.com/n8n-io/n8n/issues/13069)) ([be5e49d](https://github.com/n8n-io/n8n/commit/be5e49d56c09d65c9768e948471626cfd3606c0c))
# [1.77.0](https://github.com/n8n-io/n8n/compare/n8n@1.76.0...n8n@1.77.0) (2025-01-29)
### Bug Fixes
* **core:** Account for pre-execution failure in scaling mode ([#12815](https://github.com/n8n-io/n8n/issues/12815)) ([b4d27c4](https://github.com/n8n-io/n8n/commit/b4d27c49e32bfacbd2690bf1c07194562f6a4a61))
* **core:** Display the last activated plan name when multiple are activated ([#12835](https://github.com/n8n-io/n8n/issues/12835)) ([03365f0](https://github.com/n8n-io/n8n/commit/03365f096d3d5c8e3a6537f37cda412959705346))
* **core:** Fix possible corruption of OAuth2 credential ([#12880](https://github.com/n8n-io/n8n/issues/12880)) ([ac84ea1](https://github.com/n8n-io/n8n/commit/ac84ea14452cbcec95f14073e8e70427169e6a7f))
* **core:** Fix usage of external libs in task runner ([#12788](https://github.com/n8n-io/n8n/issues/12788)) ([3d9d5bf](https://github.com/n8n-io/n8n/commit/3d9d5bf9d58f3c49830d42a140d6c8c6b59952dc))
* **core:** Handle max stalled count error better ([#12824](https://github.com/n8n-io/n8n/issues/12824)) ([eabf160](https://github.com/n8n-io/n8n/commit/eabf1609577cd94a6bad5020c34378d840a13bc0))
* **core:** Improve error handling in credential decryption and parsing ([#12868](https://github.com/n8n-io/n8n/issues/12868)) ([0c86bf2](https://github.com/n8n-io/n8n/commit/0c86bf2b3761bb93fd3cedba7a483ae5d97bd332))
* **core:** Renew license on startup for instances with detached floating entitlements ([#12884](https://github.com/n8n-io/n8n/issues/12884)) ([f32eef8](https://github.com/n8n-io/n8n/commit/f32eef85bd066ee9b54d110355c6b80124d67437))
* **core:** Update execution entity and execution data in transaction ([#12756](https://github.com/n8n-io/n8n/issues/12756)) ([1f43181](https://github.com/n8n-io/n8n/commit/1f4318136011bffaad04527790a9eba79effce35))
* **core:** Validate credential data before encryption ([#12885](https://github.com/n8n-io/n8n/issues/12885)) ([3d27a14](https://github.com/n8n-io/n8n/commit/3d27a1498702206b738cf978d037191306cec42b))
* **editor:** Add notice when user hits the limit for execution metadata item length ([#12676](https://github.com/n8n-io/n8n/issues/12676)) ([02df25c](https://github.com/n8n-io/n8n/commit/02df25c450a0a384a32d0815d8a2faec7562a8ae))
* **editor:** Don't send run data for full manual executions ([#12687](https://github.com/n8n-io/n8n/issues/12687)) ([9139dc3](https://github.com/n8n-io/n8n/commit/9139dc3c2916186648fb5bf63d14fcb90773eb1c))
* **editor:** Fix sub-execution links in empty output tables ([#12781](https://github.com/n8n-io/n8n/issues/12781)) ([114ed88](https://github.com/n8n-io/n8n/commit/114ed88368d137443b9c6605d4fe11b02053549d))
* **editor:** Fix workflow move project select filtering ([#12764](https://github.com/n8n-io/n8n/issues/12764)) ([358d284](https://github.com/n8n-io/n8n/commit/358d2843e5e468071d6764419169811e93138c35))
* **editor:** Focus executions iframe when n8n is ready to delegate keyboard events ([#12741](https://github.com/n8n-io/n8n/issues/12741)) ([d506218](https://github.com/n8n-io/n8n/commit/d5062189dbca02dfdf485fc220cc2a7b05e3e6cc))
* **editor:** Handle large payloads in the AI Assistant requests better ([#12747](https://github.com/n8n-io/n8n/issues/12747)) ([eb4dea1](https://github.com/n8n-io/n8n/commit/eb4dea1ca891bb7ac07c8bbbae8803de080c4623))
* **editor:** Hide Set up Template button for empty workflows ([#12808](https://github.com/n8n-io/n8n/issues/12808)) ([36e615b](https://github.com/n8n-io/n8n/commit/36e615b28f395623457bbb9bf4ab6fd69102b6ea))
* **editor:** Load appropriate credentials in canvas V2 for new workflow ([#12722](https://github.com/n8n-io/n8n/issues/12722)) ([2020dc5](https://github.com/n8n-io/n8n/commit/2020dc502feae6cae827dfbcc40ffed89bcc334a))
* **editor:** Properly set active project in new canvas ([#12810](https://github.com/n8n-io/n8n/issues/12810)) ([648c6f9](https://github.com/n8n-io/n8n/commit/648c6f9315b16b885e04716e7e0035a73b358fb0))
* **editor:** Render inline SVGs correctly on the external secrets settings page ([#12802](https://github.com/n8n-io/n8n/issues/12802)) ([5820ade](https://github.com/n8n-io/n8n/commit/5820ade1e4b9d638c9b6369aef369d6dc9320da6))
* **editor:** Show input selector when node has error ([#12813](https://github.com/n8n-io/n8n/issues/12813)) ([5b760e7](https://github.com/n8n-io/n8n/commit/5b760e7f7fc612b10307b4871e24b549f5d9d420))
* **editor:** Show mappings by default in sub-node NDVs when the root node isn't executed ([#12642](https://github.com/n8n-io/n8n/issues/12642)) ([fb662dd](https://github.com/n8n-io/n8n/commit/fb662dd95cae3bc51d05d05e32e772d05adafa1e))
* **Postgres PGVector Store Node:** Release postgres connections back to the pool ([#12723](https://github.com/n8n-io/n8n/issues/12723)) ([663dfb4](https://github.com/n8n-io/n8n/commit/663dfb48defd944f88f0ecc4f3347ea4f8a7c831))
### Features
* Add DeepSeek Chat Model node ([#12873](https://github.com/n8n-io/n8n/issues/12873)) ([9918afa](https://github.com/n8n-io/n8n/commit/9918afa51b16116abb73692a66df84e48128f406))
* Add OpenRouter node ([#12882](https://github.com/n8n-io/n8n/issues/12882)) ([dc85b02](https://github.com/n8n-io/n8n/commit/dc85b022d111d1e8b038ca1a9f6a1041f19cf2b0))
* Add timeout options to sendAndWait operations ([#12753](https://github.com/n8n-io/n8n/issues/12753)) ([3e9f24d](https://github.com/n8n-io/n8n/commit/3e9f24ddf462349145d89fe183313c95512c699b))
* **API:** Add route for schema static files ([#12770](https://github.com/n8n-io/n8n/issues/12770)) ([d981b56](https://github.com/n8n-io/n8n/commit/d981b5659a26f92b11e5d0cd5570504fd683626c))
* **core:** Explicitly report external hook failures ([#12830](https://github.com/n8n-io/n8n/issues/12830)) ([a24e442](https://github.com/n8n-io/n8n/commit/a24e4420bb9023f808acd756d125dffaea325968))
* **core:** Rename two task runner env vars ([#12763](https://github.com/n8n-io/n8n/issues/12763)) ([60187ca](https://github.com/n8n-io/n8n/commit/60187cab9bc9d21aa6ba710d772c068324e429f1))
* **editor:** Add evaluation workflow and enhance workflow selector with pinned data support ([#12773](https://github.com/n8n-io/n8n/issues/12773)) ([be967eb](https://github.com/n8n-io/n8n/commit/be967ebec07fab223513f93f50bcc389b9a4c548))
* **editor:** Always keep at least one executing node indicator in the workflow ([#12829](https://github.com/n8n-io/n8n/issues/12829)) ([c25c613](https://github.com/n8n-io/n8n/commit/c25c613a04a6773fa4014d9a0d290e443bcabbe0))
* **Google Chat Node:** Updates ([#12827](https://github.com/n8n-io/n8n/issues/12827)) ([e146ad0](https://github.com/n8n-io/n8n/commit/e146ad021a0be22cf51bafa3c015d03550e03d97))
* **Microsoft Outlook Node:** New operation sendAndWait ([#12795](https://github.com/n8n-io/n8n/issues/12795)) ([f4bf55f](https://github.com/n8n-io/n8n/commit/f4bf55f0d8278ff954344cf6397c10d8261b39a4))
* **n8n Form Node:** Add read-only/custom HTML form elements ([#12760](https://github.com/n8n-io/n8n/issues/12760)) ([ba8aa39](https://github.com/n8n-io/n8n/commit/ba8aa3921613c590caaac627fbb9837ccaf87783))
* **Send Email Node:** New operation sendAndWait ([#12775](https://github.com/n8n-io/n8n/issues/12775)) ([a197fbb](https://github.com/n8n-io/n8n/commit/a197fbb21b5642843d8bc3e657049aca99e0729d))
* **Summarize Node:** Turns error when field not found in items into warning ([#11889](https://github.com/n8n-io/n8n/issues/11889)) ([d7dda3f](https://github.com/n8n-io/n8n/commit/d7dda3f5de52925e554455f9f10e51bd173ea856))
* **Telegram Node:** New operation sendAndWait ([#12771](https://github.com/n8n-io/n8n/issues/12771)) ([2c58d47](https://github.com/n8n-io/n8n/commit/2c58d47f8eee1f865ecc1eeb89aa20c69c28abae))
# [1.76.0](https://github.com/n8n-io/n8n/compare/n8n@1.75.0...n8n@1.76.0) (2025-01-22)
### Bug Fixes
* **core:** Align saving behavior in `workflowExecuteAfter` hooks ([#12731](https://github.com/n8n-io/n8n/issues/12731)) ([9d76210](https://github.com/n8n-io/n8n/commit/9d76210a570e025d01d1f6596667abf40fbd8d12))
* **core:** AugmentObject should handle the constructor property correctly ([#12744](https://github.com/n8n-io/n8n/issues/12744)) ([36bc164](https://github.com/n8n-io/n8n/commit/36bc164da486f2e2d05091b457b8eea6521ca22e))
* **core:** Fix keyboard shortcuts for non-ansi layouts ([#12672](https://github.com/n8n-io/n8n/issues/12672)) ([4c8193f](https://github.com/n8n-io/n8n/commit/4c8193fedc2e3967c9a06c0652483128df509653))
* **core:** Fix license CLI commands showing incorrect renewal setting ([#12759](https://github.com/n8n-io/n8n/issues/12759)) ([024ada8](https://github.com/n8n-io/n8n/commit/024ada822c1bc40958e594bb08707cf77d3397ec))
* **core:** Fix license initialization failure on startup ([#12737](https://github.com/n8n-io/n8n/issues/12737)) ([ac2f647](https://github.com/n8n-io/n8n/commit/ac2f6476c114f51fafb9b7b66e41e0c87f4a1bf6))
* **core:** Recover successful data-less executions ([#12720](https://github.com/n8n-io/n8n/issues/12720)) ([a39b8bd](https://github.com/n8n-io/n8n/commit/a39b8bd32be50c8323e415f820b25b4bcb81d960))
* **core:** Remove run data of utility nodes for partial executions v2 ([#12673](https://github.com/n8n-io/n8n/issues/12673)) ([b66a9dc](https://github.com/n8n-io/n8n/commit/b66a9dc8fb6f7b19122cacbb7e2f86b4c921c3fb))
* **core:** Sync `hookFunctionsSave` and `hookFunctionsSaveWorker` ([#12740](https://github.com/n8n-io/n8n/issues/12740)) ([d410b8f](https://github.com/n8n-io/n8n/commit/d410b8f5a7e99658e1e8dcb2e02901bd01ce9c59))
* **core:** Update isDocker check to return true on kubernetes/containerd ([#12603](https://github.com/n8n-io/n8n/issues/12603)) ([c55dac6](https://github.com/n8n-io/n8n/commit/c55dac66ed97a2317d4c696c3b505790ec5d72fe))
* **editor:** Add unicode code points to expression language for emoji ([#12633](https://github.com/n8n-io/n8n/issues/12633)) ([819ebd0](https://github.com/n8n-io/n8n/commit/819ebd058d1d60b3663d92b4a652728da7134a3b))
* **editor:** Correct missing whitespace in JSON output ([#12677](https://github.com/n8n-io/n8n/issues/12677)) ([b098b19](https://github.com/n8n-io/n8n/commit/b098b19c7f0e3a9848c3fcfa012999050f2d3c7a))
* **editor:** Defer crypto.randomUUID call in CodeNodeEditor ([#12630](https://github.com/n8n-io/n8n/issues/12630)) ([58f6532](https://github.com/n8n-io/n8n/commit/58f6532630bacd288d3c0a79b40150f465898419))
* **editor:** Fix Code node bug erasing and overwriting code when switching between nodes ([#12637](https://github.com/n8n-io/n8n/issues/12637)) ([02d953d](https://github.com/n8n-io/n8n/commit/02d953db34ec4e44977a8ca908628b62cca82fde))
* **editor:** Fix execution list hover & selection colour in dark mode ([#12628](https://github.com/n8n-io/n8n/issues/12628)) ([95c40c0](https://github.com/n8n-io/n8n/commit/95c40c02cb8fef77cf633cf5aec08e98746cff36))
* **editor:** Fix JsonEditor with expressions ([#12739](https://github.com/n8n-io/n8n/issues/12739)) ([56c93ca](https://github.com/n8n-io/n8n/commit/56c93caae026738c1c0bebb4187b238e34a330f6))
* **editor:** Fix navbar height flickering during load ([#12738](https://github.com/n8n-io/n8n/issues/12738)) ([a96b3f0](https://github.com/n8n-io/n8n/commit/a96b3f0091798a52bb33107b919b5d8287ba7506))
* **editor:** Open chat when executing agent node in canvas v2 ([#12617](https://github.com/n8n-io/n8n/issues/12617)) ([457edd9](https://github.com/n8n-io/n8n/commit/457edd99bb853d8ccf3014605d5823933f3c0bc6))
* **editor:** Partial execution of a workflow with manual chat trigger ([#12662](https://github.com/n8n-io/n8n/issues/12662)) ([2f81b29](https://github.com/n8n-io/n8n/commit/2f81b29d341535b512df0aa01b25a91d109f113f))
* **editor:** Show connector label above the line when it's straight ([#12622](https://github.com/n8n-io/n8n/issues/12622)) ([c97bd48](https://github.com/n8n-io/n8n/commit/c97bd48a77643b9c2a5d7218e21b957af15cee0b))
* **editor:** Show run workflow button when chat trigger has pinned data ([#12616](https://github.com/n8n-io/n8n/issues/12616)) ([da8aafc](https://github.com/n8n-io/n8n/commit/da8aafc0e3a1b5d862f0723d0d53d2c38bcaebc3))
* **editor:** Update workflow re-initialization to use query parameter ([#12650](https://github.com/n8n-io/n8n/issues/12650)) ([982131a](https://github.com/n8n-io/n8n/commit/982131a75a32f741c120156826c303989aac189c))
* **Execute Workflow Node:** Pass binary data to sub-workflow ([#12635](https://github.com/n8n-io/n8n/issues/12635)) ([e9c152e](https://github.com/n8n-io/n8n/commit/e9c152e369a4c2762bd8e6ad17eaa704bb3771bb))
* **Google Gemini Chat Model Node:** Add base URL support for Google Gemini Chat API ([#12643](https://github.com/n8n-io/n8n/issues/12643)) ([14f4bc7](https://github.com/n8n-io/n8n/commit/14f4bc769027789513808b4000444edf99dc5d1c))
* **GraphQL Node:** Change default request format to json instead of graphql ([#11346](https://github.com/n8n-io/n8n/issues/11346)) ([c7c122f](https://github.com/n8n-io/n8n/commit/c7c122f9173df824cc1b5ab864333bffd0d31f82))
* **Jira Software Node:** Get custom fields(RLC) in update operation for server deployment type ([#12719](https://github.com/n8n-io/n8n/issues/12719)) ([353df79](https://github.com/n8n-io/n8n/commit/353df7941117e20547cd4f3fc514979a54619720))
* **n8n Form Node:** Remove the ability to change the formatting of dates ([#12666](https://github.com/n8n-io/n8n/issues/12666)) ([14904ff](https://github.com/n8n-io/n8n/commit/14904ff77951fef23eb789a43947492a4cd3fa20))
* **OpenAI Chat Model Node:** Fix loading of custom models when using custom credential URL ([#12634](https://github.com/n8n-io/n8n/issues/12634)) ([7cc553e](https://github.com/n8n-io/n8n/commit/7cc553e3b277a16682bfca1ea08cb98178e38580))
* **OpenAI Chat Model Node:** Restore default model value ([#12745](https://github.com/n8n-io/n8n/issues/12745)) ([d1b6692](https://github.com/n8n-io/n8n/commit/d1b6692736182fa2eab768ba3ad0adb8504ebbbd))
* **Postgres Chat Memory Node:** Do not terminate the connection pool ([#12674](https://github.com/n8n-io/n8n/issues/12674)) ([e7f00bc](https://github.com/n8n-io/n8n/commit/e7f00bcb7f2dce66ca07a9322d50f96356c1a43d))
* **Postgres Node:** Allow using composite key in upsert queries ([#12639](https://github.com/n8n-io/n8n/issues/12639)) ([83ce3a9](https://github.com/n8n-io/n8n/commit/83ce3a90963ba76601234f4314363a8ccc310f0f))
* **Wait Node:** Fix for hasNextPage in waiting forms ([#12636](https://github.com/n8n-io/n8n/issues/12636)) ([652b8d1](https://github.com/n8n-io/n8n/commit/652b8d170b9624d47b5f2d8d679c165cc14ea548))
### Features
* Add credential only node for Microsoft Azure Monitor ([#12645](https://github.com/n8n-io/n8n/issues/12645)) ([6ef8882](https://github.com/n8n-io/n8n/commit/6ef8882a108c672ab097c9dd1c590d4e9e7f3bcc))
* Add Miro credential only node ([#12746](https://github.com/n8n-io/n8n/issues/12746)) ([5b29086](https://github.com/n8n-io/n8n/commit/5b29086e2f9b7f638fac4440711f673438e57492))
* Add SSM endpoint to AWS credentials ([#12212](https://github.com/n8n-io/n8n/issues/12212)) ([565c7b8](https://github.com/n8n-io/n8n/commit/565c7b8b9cfd3e10f6a2c60add96fea4c4d95d33))
* **core:** Enable task runner by default ([#12726](https://github.com/n8n-io/n8n/issues/12726)) ([9e2a01a](https://github.com/n8n-io/n8n/commit/9e2a01aeaf36766a1cf7a1d9a4d6e02f45739bd3))
* **editor:** Force final canvas v2 migration and remove switcher from UI ([#12717](https://github.com/n8n-io/n8n/issues/12717)) ([29335b9](https://github.com/n8n-io/n8n/commit/29335b9b6acf97c817bea70688e8a2786fbd8889))
* **editor:** VariablesView Reskin - Add Filters for missing values ([#12611](https://github.com/n8n-io/n8n/issues/12611)) ([1eeb788](https://github.com/n8n-io/n8n/commit/1eeb788d327287d21eab7ad6f2156453ab7642c7))
* **Jira Software Node:** Personal Access Token credential type ([#11038](https://github.com/n8n-io/n8n/issues/11038)) ([1c7a38f](https://github.com/n8n-io/n8n/commit/1c7a38f6bab108daa47401cd98c185590bf299a8))
* **n8n Form Trigger Node:** Form Improvements ([#12590](https://github.com/n8n-io/n8n/issues/12590)) ([f167578](https://github.com/n8n-io/n8n/commit/f167578b3251e553a4d000e731e1bb60348916ad))
* Synchronize deletions when pulling from source control ([#12170](https://github.com/n8n-io/n8n/issues/12170)) ([967ee4b](https://github.com/n8n-io/n8n/commit/967ee4b89b94b92fc3955c56bf4c9cca0bd64eac))
# [1.75.0](https://github.com/n8n-io/n8n/compare/n8n@1.74.0...n8n@1.75.0) (2025-01-15)
### Bug Fixes
* **core:** AugmentObject should check for own propeties correctly ([#12534](https://github.com/n8n-io/n8n/issues/12534)) ([0cdf393](https://github.com/n8n-io/n8n/commit/0cdf39374305e6bbcedb047db7d3756168e6e89e))
* **core:** Disallow code generation in task runner ([#12522](https://github.com/n8n-io/n8n/issues/12522)) ([35b6180](https://github.com/n8n-io/n8n/commit/35b618098b7d23e272bf77b55c172dbe531c821f))
* **core:** Fix node exclusion on the frontend types ([#12544](https://github.com/n8n-io/n8n/issues/12544)) ([b2cbed9](https://github.com/n8n-io/n8n/commit/b2cbed9865888f6f3bc528984d4091d86a88f0d6))
* **core:** Fix orchestration flow with expired license ([#12444](https://github.com/n8n-io/n8n/issues/12444)) ([ecff3b7](https://github.com/n8n-io/n8n/commit/ecff3b732a028d7225bfbed4ffc65dc20c4ed608))
* **core:** Fix Sentry error reporting on task runners ([#12495](https://github.com/n8n-io/n8n/issues/12495)) ([88c0838](https://github.com/n8n-io/n8n/commit/88c0838dd72f11646bdb3586223d6c16631cccab))
* **core:** Improve cyclic dependency check in the DI container ([#12600](https://github.com/n8n-io/n8n/issues/12600)) ([c3c4a20](https://github.com/n8n-io/n8n/commit/c3c4a200024fb08afb9380357d1490c6707c5ec3))
* **core:** Only show personal credentials in the personal space ([#12433](https://github.com/n8n-io/n8n/issues/12433)) ([8a42d55](https://github.com/n8n-io/n8n/commit/8a42d55d91f4a37fff5669d52d52428b3a4ddd44))
* **core:** Prefix package name in `supportedNodes` on generated types as well ([#12514](https://github.com/n8n-io/n8n/issues/12514)) ([4a1a999](https://github.com/n8n-io/n8n/commit/4a1a9993624c92dd81f5418f9268cb93878069ab))
* **core:** Prevent prototype pollution in task runner ([#12588](https://github.com/n8n-io/n8n/issues/12588)) ([bdf266c](https://github.com/n8n-io/n8n/commit/bdf266cf55032d05641b20dce8804412dc93b6d5))
* **core:** Prevent prototype pollution of internal classes in task runner ([#12610](https://github.com/n8n-io/n8n/issues/12610)) ([eceee7f](https://github.com/n8n-io/n8n/commit/eceee7f3f8899d200b1c5720087cc494eec22e6a))
* **core:** Use timing safe function to compare runner auth tokens ([#12485](https://github.com/n8n-io/n8n/issues/12485)) ([8fab98f](https://github.com/n8n-io/n8n/commit/8fab98f3f1f767d05825d24cbf155d56375fdb3e))
* **core:** Validate values which are intentionally 0 ([#12382](https://github.com/n8n-io/n8n/issues/12382)) ([562506e](https://github.com/n8n-io/n8n/commit/562506e92aeb26423145801bff80037e5ce2ac46))
* Don't break oauth credentials when updating them and allow fixing broken oauth credentials by repeating the authorization flow ([#12563](https://github.com/n8n-io/n8n/issues/12563)) ([73897c7](https://github.com/n8n-io/n8n/commit/73897c7662a432834eb6f9d0f9ace8d986c1acb5))
* **editor:** Don't show toolsUnused notice if run had errors ([#12529](https://github.com/n8n-io/n8n/issues/12529)) ([3ec5b28](https://github.com/n8n-io/n8n/commit/3ec5b2850c47057032e61c2acdbdfc1dcdd931f7))
* **editor:** Ensure proper "AI Template" URL construction in node creator ([#12566](https://github.com/n8n-io/n8n/issues/12566)) ([13bf69f](https://github.com/n8n-io/n8n/commit/13bf69f75c67bc37a37013e776525768676a4b88))
* **editor:** Fix NDV resize handle and scrollbar overlapping ([#12509](https://github.com/n8n-io/n8n/issues/12509)) ([c28f302](https://github.com/n8n-io/n8n/commit/c28f302c2f863bd7aa73ad52e5d040f927e33220))
* **editor:** Fix parameter input validation ([#12532](https://github.com/n8n-io/n8n/issues/12532)) ([6711cbc](https://github.com/n8n-io/n8n/commit/6711cbcc641a2fc70f5c15a7e2dcc640a3f98b66))
* **editor:** Fix selection rectangle context menu on new canvas ([#12584](https://github.com/n8n-io/n8n/issues/12584)) ([c8e3c53](https://github.com/n8n-io/n8n/commit/c8e3c5399efde93486c1dd5c373cb2c5ff8a0691))
* **editor:** Fix the `openselectivenodecreator` custom action on new canvas ([#12580](https://github.com/n8n-io/n8n/issues/12580)) ([2110e9a](https://github.com/n8n-io/n8n/commit/2110e9a0513b8c36beb85302e0d38a2658ea5d6e))
* **editor:** Fix workflow initilisation for test definition routes & add unit tests ([#12507](https://github.com/n8n-io/n8n/issues/12507)) ([2775f61](https://github.com/n8n-io/n8n/commit/2775f617ae5c267c0a1ce7a54d05d4077cdbc0f7))
* **editor:** Make clicking item in RLC work the first time on small screens ([#12585](https://github.com/n8n-io/n8n/issues/12585)) ([479933f](https://github.com/n8n-io/n8n/commit/479933fbd5c88e783827960e018abb979de8a039))
* **editor:** Make sure code editors work correctly in fullscreen ([#12597](https://github.com/n8n-io/n8n/issues/12597)) ([aa1f3a7](https://github.com/n8n-io/n8n/commit/aa1f3a7d989883d55df3777775b8d7d336f6e3b7))
* **editor:** Override selected nodes on single click without Meta/Ctrl key ([#12549](https://github.com/n8n-io/n8n/issues/12549)) ([02c2d5e](https://github.com/n8n-io/n8n/commit/02c2d5e71d15b9292fddd585f47bd8334da468c5))
* **editor:** Show NDV errors when opening existing nodes with errors ([#12567](https://github.com/n8n-io/n8n/issues/12567)) ([bee7267](https://github.com/n8n-io/n8n/commit/bee7267fe38ab12a79fa4ec0e775f45d98d48aa5))
* **editor:** Swap Activate/Deactivate texts in FloatingToolbar ([#12526](https://github.com/n8n-io/n8n/issues/12526)) ([44679b4](https://github.com/n8n-io/n8n/commit/44679b42aa1e14bc7069bee47d0a91ca84b1dba4))
* **editor:** Update filter and feedback for source control ([#12504](https://github.com/n8n-io/n8n/issues/12504)) ([865fc21](https://github.com/n8n-io/n8n/commit/865fc21276727e8d88ccee0355147904b81c4421))
* **editor:** Update selected node when navigating via flowing nodes ([#12581](https://github.com/n8n-io/n8n/issues/12581)) ([88659d8](https://github.com/n8n-io/n8n/commit/88659d8a2901786c894902e19466f395bcdaab8e))
* **Google Calendar Node:** Updates and fixes ([#10715](https://github.com/n8n-io/n8n/issues/10715)) ([7227a29](https://github.com/n8n-io/n8n/commit/7227a29845fd178ced4d281597c62e7a03245456))
* **Spotify Node:** Fix issue with null values breaking the response ([#12080](https://github.com/n8n-io/n8n/issues/12080)) ([a56a462](https://github.com/n8n-io/n8n/commit/a56a46259d257003c813103578260d625b3f17dd))
### Features
* **editor:** Make node credential select searchable ([#12497](https://github.com/n8n-io/n8n/issues/12497)) ([91277c4](https://github.com/n8n-io/n8n/commit/91277c44f1cf3f334b3b50d47d7dcc79b11c7c63))
* **editor:** Persist sidebar collapsed status preference ([#12505](https://github.com/n8n-io/n8n/issues/12505)) ([dba7d46](https://github.com/n8n-io/n8n/commit/dba7d46f3ec91d26a597a50dede7b6ca292c728f))

View file

@ -85,7 +85,7 @@ This automatically sets up file-links between modules which depend on each other
We recommend enabling [Node.js corepack](https://nodejs.org/docs/latest-v16.x/api/corepack.html) with `corepack enable`.
With Node.js v16.17 or newer, you can install the latest version of pnpm: `corepack prepare pnpm@latest --activate`. If you use an older version install at least version 7.18 of pnpm via: `corepack prepare pnpm@7.18.0 --activate`.
With Node.js v16.17 or newer, you can install the latest version of pnpm: `corepack prepare pnpm@latest --activate`. If you use an older version install at least version 9.15 of pnpm via: `corepack prepare pnpm@9.15.5 --activate`.
**IMPORTANT**: If you have installed Node.js via homebrew, you'll need to run `brew install corepack`, since homebrew explicitly removes `npm` and `corepack` from [the `node` formula](https://github.com/Homebrew/homebrew-core/blob/master/Formula/node.rb#L66).

View file

@ -46,6 +46,7 @@ component_management:
- packages/@n8n/codemirror-lang/**
- packages/design-system/**
- packages/editor-ui/**
- packages/frontend/**
- component_id: nodes_packages
name: Nodes
paths:

View file

@ -2,6 +2,8 @@
* Getters
*/
import { clearNotifications } from '../../pages/notifications';
export function getCredentialConnectionParameterInputs() {
return cy.getByTestId('credential-connection-parameter');
}
@ -55,5 +57,6 @@ export function setCredentialValues(values: Record<string, string>, save = true)
if (save) {
saveCredential();
closeCredentialModal();
clearNotifications();
}
}

View file

@ -2,7 +2,11 @@
* Getters
*/
import { getVisibleSelect } from '../utils/popper';
import { getVisiblePopper, getVisibleSelect } from '../utils/popper';
export function getNdvContainer() {
return cy.getByTestId('ndv');
}
export function getCredentialSelect(eq = 0) {
return cy.getByTestId('node-credentials-select').eq(eq);
@ -36,6 +40,18 @@ export function getOutputPanel() {
return cy.getByTestId('output-panel');
}
export function getFixedCollection(collectionName: string) {
return cy.getByTestId(`fixed-collection-${collectionName}`);
}
export function getResourceLocator(paramName: string) {
return cy.getByTestId(`resource-locator-${paramName}`);
}
export function getResourceLocatorInput(paramName: string) {
return getResourceLocator(paramName).find('[data-test-id="rlc-input-container"]');
}
export function getOutputPanelDataContainer() {
return getOutputPanel().getByTestId('ndv-data-container');
}
@ -84,6 +100,30 @@ export function getOutputPanelRelatedExecutionLink() {
return getOutputPanel().getByTestId('related-execution-link');
}
export function getNodeOutputHint() {
return cy.getByTestId('ndv-output-run-node-hint');
}
export function getWorkflowCards() {
return cy.getByTestId('resources-list-item');
}
export function getWorkflowCard(workflowName: string) {
return getWorkflowCards().contains(workflowName).parents('[data-test-id="resources-list-item"]');
}
export function getWorkflowCardContent(workflowName: string) {
return getWorkflowCard(workflowName).findChildByTestId('card-content');
}
export function getNodeRunInfoStale() {
return cy.getByTestId('node-run-info-stale');
}
export function getNodeOutputErrorMessage() {
return getOutputPanel().findChildByTestId('node-error-message');
}
/**
* Actions
*/
@ -110,12 +150,20 @@ export function clickExecuteNode() {
getExecuteNodeButton().click();
}
export function clickResourceLocatorInput(paramName: string) {
getResourceLocatorInput(paramName).click();
}
export function setParameterInputByName(name: string, value: string) {
getParameterInputByName(name).clear().type(value);
}
export function toggleParameterCheckboxInputByName(name: string) {
getParameterInputByName(name).find('input[type="checkbox"]').realClick();
export function checkParameterCheckboxInputByName(name: string) {
getParameterInputByName(name).find('input[type="checkbox"]').check({ force: true });
}
export function uncheckParameterCheckboxInputByName(name: string) {
getParameterInputByName(name).find('input[type="checkbox"]').uncheck({ force: true });
}
export function setParameterSelectByContent(name: string, content: string) {
@ -127,3 +175,86 @@ export function changeOutputRunSelector(runName: string) {
getOutputRunSelector().click();
getVisibleSelect().find('.el-select-dropdown__item').contains(runName).click();
}
export function addItemToFixedCollection(collectionName: string) {
getFixedCollection(collectionName).getByTestId('fixed-collection-add').click();
}
export function typeIntoFixedCollectionItem(collectionName: string, index: number, value: string) {
getFixedCollection(collectionName).within(() =>
cy.getByTestId('parameter-input').eq(index).type(value),
);
}
export function selectResourceLocatorItem(
resourceLocator: string,
index: number,
expectedText: string,
) {
clickResourceLocatorInput(resourceLocator);
getVisiblePopper().findChildByTestId('rlc-item').eq(0).should('exist');
getVisiblePopper()
.findChildByTestId('rlc-item')
.eq(index)
.find('span')
.should('contain.text', expectedText)
.click();
}
export function clickWorkflowCardContent(workflowName: string) {
getWorkflowCardContent(workflowName).click();
}
export function assertNodeOutputHintExists() {
getNodeOutputHint().should('exist');
}
export function assertNodeOutputErrorMessageExists() {
return getNodeOutputErrorMessage().should('exist');
}
// Note that this only validates the expectedContent is *included* in the output table
export function assertOutputTableContent(expectedContent: unknown[][]) {
for (const [i, row] of expectedContent.entries()) {
for (const [j, value] of row.entries()) {
// + 1 to skip header
getOutputTbodyCell(1 + i, j).should('have.text', value);
}
}
}
export function populateMapperFields(fields: ReadonlyArray<[string, string]>) {
for (const [name, value] of fields) {
getParameterInputByName(name).type(value);
// Click on a parent to dismiss the pop up which hides the field below.
getParameterInputByName(name).parent().parent().parent().parent().click('topLeft');
}
}
/**
* Populate multiValue fixedCollections. Only supports fixedCollections for which all fields can be defined via keyboard typing
*
* @param items - 2D array of items to populate, i.e. [["myField1", "String"], ["myField2", "Number"]]
* @param collectionName - name of the fixedCollection to populate
* @param offset - amount of 'parameter-input's before start, e.g. from a controlling dropdown that makes the fields appear
* @returns
*/
export function populateFixedCollection<T extends readonly string[]>(
items: readonly T[],
collectionName: string,
offset: number = 0,
) {
if (items.length === 0) return;
const n = items[0].length;
for (const [i, params] of items.entries()) {
addItemToFixedCollection(collectionName);
for (const [j, param] of params.entries()) {
getFixedCollection(collectionName)
.getByTestId('parameter-input')
.eq(offset + i * n + j)
.type(`${param}{downArrow}{enter}`);
}
}
}

View file

@ -0,0 +1,81 @@
import { BACKEND_BASE_URL } from '../constants';
import { NDV, WorkflowPage } from '../pages';
import { getVisibleSelect } from '../utils';
export const waitForWebhook = 500;
export interface SimpleWebhookCallOptions {
method: string;
webhookPath: string;
responseCode?: number;
respondWith?: string;
executeNow?: boolean;
responseData?: string;
authentication?: string;
}
const workflowPage = new WorkflowPage();
const ndv = new NDV();
export const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
const {
authentication,
method,
webhookPath,
responseCode,
respondWith,
responseData,
executeNow = true,
} = options;
workflowPage.actions.addInitialNodeToCanvas('Webhook');
workflowPage.actions.openNode('Webhook');
cy.getByTestId('parameter-input-httpMethod').click();
getVisibleSelect().find('.option-headline').contains(method).click();
cy.getByTestId('parameter-input-path')
.find('.parameter-input')
.find('input')
.clear()
.type(webhookPath);
if (authentication) {
cy.getByTestId('parameter-input-authentication').click();
getVisibleSelect().find('.option-headline').contains(authentication).click();
}
if (responseCode) {
cy.get('.param-options').click();
getVisibleSelect().contains('Response Code').click();
cy.get('.parameter-item-wrapper > .parameter-input-list-wrapper').children().click();
getVisibleSelect().contains('201').click();
}
if (respondWith) {
cy.getByTestId('parameter-input-responseMode').click();
getVisibleSelect().find('.option-headline').contains(respondWith).click();
}
if (responseData) {
cy.getByTestId('parameter-input-responseData').click();
getVisibleSelect().find('.option-headline').contains(responseData).click();
}
const callEndpoint = (fn: (response: Cypress.Response<unknown>) => void) => {
cy.request(method, `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then(fn);
};
if (executeNow) {
ndv.actions.execute();
cy.wait(waitForWebhook);
callEndpoint((response) => {
expect(response.status).to.eq(200);
ndv.getters.outputPanel().contains('headers');
});
}
return {
callEndpoint,
};
};

View file

@ -1,12 +1,14 @@
import { getManualChatModal } from './modals/chat-modal';
import { clickGetBackToCanvas, getParameterInputByName } from './ndv';
import { ROUTES } from '../constants';
import type { OpenContextMenuOptions } from '../types';
/**
* Types
*/
export type EndpointType =
| 'main'
| 'ai_chain'
| 'ai_document'
| 'ai_embedding'
@ -23,9 +25,75 @@ export type EndpointType =
* Getters
*/
export function getAddInputEndpointByType(nodeName: string, endpointType: EndpointType) {
return cy.get(
`.add-input-endpoint[data-jtk-scope-${endpointType}][data-endpoint-name="${nodeName}"]`,
export function getCanvas() {
return cy.getByTestId('canvas');
}
export function getCanvasPane() {
return cy.ifCanvasVersion(
() => cy.getByTestId('node-view-background'),
() => getCanvas().find('.vue-flow__pane'),
);
}
export function getContextMenu() {
return cy.getByTestId('context-menu').find('.el-dropdown-menu');
}
export function getContextMenuAction(action: string) {
return cy.getByTestId(`context-menu-item-${action}`);
}
export function getInputPlusHandle(nodeName: string) {
return cy.ifCanvasVersion(
() => cy.get(`.add-input-endpoint[data-endpoint-name="${nodeName}"]`),
() =>
cy.get(
`[data-test-id="canvas-node-input-handle"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"]`,
),
);
}
export function getInputPlusHandleByType(nodeName: string, endpointType: EndpointType) {
return cy.ifCanvasVersion(
() =>
cy.get(
`.add-input-endpoint[data-jtk-scope-${endpointType}][data-endpoint-name="${nodeName}"]`,
),
() =>
cy.get(
`[data-test-id="canvas-node-input-handle"][data-connection-type="${endpointType}"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"]`,
),
);
}
export function getOutputHandle(nodeName: string) {
return cy.ifCanvasVersion(
() => cy.get(`.add-output-endpoint[data-endpoint-name="${nodeName}"]`),
() => cy.get(`[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"]`),
);
}
export function getOutputPlusHandle(nodeName: string) {
return cy.ifCanvasVersion(
() => cy.get(`.add-output-endpoint[data-endpoint-name="${nodeName}"]`),
() =>
cy.get(
`[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"]`,
),
);
}
export function getOutputPlusHandleByType(nodeName: string, endpointType: EndpointType) {
return cy.ifCanvasVersion(
() =>
cy.get(
`.add-output-endpoint[data-jtk-scope-${endpointType}][data-endpoint-name="${nodeName}"]`,
),
() =>
cy.get(
`[data-test-id="canvas-node-output-handle"][data-connection-type="${endpointType}"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"]`,
),
);
}
@ -33,8 +101,8 @@ export function getNodeCreatorItems() {
return cy.getByTestId('item-iterator-item');
}
export function getExecuteWorkflowButton() {
return cy.getByTestId('execute-workflow-button');
export function getExecuteWorkflowButton(triggerNodeName?: string) {
return cy.getByTestId(`execute-workflow-button${triggerNodeName ? `-${triggerNodeName}` : ''}`);
}
export function getManualChatButton() {
@ -52,6 +120,13 @@ export function getNodeByName(name: string) {
);
}
export function getNodeRenderedTypeByName(name: string) {
return cy.ifCanvasVersion(
() => getNodeByName(name),
() => getNodeByName(name).find('[data-canvas-node-render-type]'),
);
}
export function getWorkflowHistoryCloseButton() {
return cy.getByTestId('workflow-history-close-button');
}
@ -63,10 +138,24 @@ export function disableNode(name: string) {
}
export function getConnectionBySourceAndTarget(source: string, target: string) {
return cy.ifCanvasVersion(
() =>
cy
.get('.jtk-connector')
.filter(`[data-source-node="${source}"][data-target-node="${target}"]`)
.eq(0),
() =>
cy
.getByTestId('edge')
.filter(`[data-source-node-name="${source}"][data-target-node-name="${target}"]`)
.eq(0),
);
}
export function getConnectionLabelBySourceAndTarget(source: string, target: string) {
return cy
.get('.jtk-connector')
.filter(`[data-source-node="${source}"][data-target-node="${target}"]`)
.eq(0);
.getByTestId('edge-label')
.filter(`[data-source-node-name="${source}"][data-target-node-name="${target}"]`);
}
export function getNodeCreatorSearchBar() {
@ -78,10 +167,7 @@ export function getNodeCreatorPlusButton() {
}
export function getCanvasNodes() {
return cy.ifCanvasVersion(
() => cy.getByTestId('canvas-node'),
() => cy.getByTestId('canvas-node').not('[data-node-type="n8n-nodes-internal.addNodes"]'),
);
return cy.getByTestId('canvas-node');
}
export function getCanvasNodeByName(nodeName: string) {
@ -141,7 +227,7 @@ function connectNodeToParent(
parentNodeName: string,
exactMatch = false,
) {
getAddInputEndpointByType(parentNodeName, endpointType).click({ force: true });
getInputPlusHandleByType(parentNodeName, endpointType).click({ force: true });
if (exactMatch) {
getNodeCreatorItems()
.contains(new RegExp('^' + nodeName + '$', 'g'))
@ -158,7 +244,19 @@ export function addSupplementalNodeToParent(
exactMatch = false,
) {
connectNodeToParent(nodeName, endpointType, parentNodeName, exactMatch);
getConnectionBySourceAndTarget(parentNodeName, nodeName).should('exist');
cy.ifCanvasVersion(
() => {
getConnectionBySourceAndTarget(parentNodeName, nodeName).should('exist');
},
() => {
if (endpointType === 'main') {
getConnectionBySourceAndTarget(parentNodeName, nodeName).should('exist');
} else {
getConnectionBySourceAndTarget(nodeName, parentNodeName).should('exist');
}
},
);
}
export function addLanguageModelNodeToParent(
@ -196,8 +294,8 @@ export function addRetrieverNodeToParent(nodeName: string, parentNodeName: strin
addSupplementalNodeToParent(nodeName, 'ai_retriever', parentNodeName);
}
export function clickExecuteWorkflowButton() {
getExecuteWorkflowButton().click();
export function clickExecuteWorkflowButton(triggerNodeName?: string) {
getExecuteWorkflowButton(triggerNodeName).click();
}
export function clickManualChatButton() {
@ -229,3 +327,34 @@ export function deleteNode(name: string) {
getCanvasNodeByName(name).first().click();
cy.get('body').type('{del}');
}
export function openContextMenu(
nodeName?: string,
{ method = 'right-click', anchor = 'center' }: OpenContextMenuOptions = {},
) {
let target;
if (nodeName) {
target =
method === 'right-click' ? getNodeRenderedTypeByName(nodeName) : getNodeByName(nodeName);
} else {
target = getCanvasPane();
}
if (method === 'right-click') {
target.rightclick(nodeName ? anchor : 'topLeft', { force: true });
} else {
target.realHover();
target.find('[data-test-id="overflow-node-button"]').click({ force: true });
}
cy.ifCanvasVersion(
() => {},
() => {
getContextMenu().should('be.visible');
},
);
}
export function clickContextMenuAction(action: string) {
getContextMenuAction(action).click();
}

View file

@ -0,0 +1,15 @@
/**
* Getters
*/
export function getWorkflowsPageUrl() {
return '/home/workflows';
}
/**
* Actions
*/
export function visitWorkflowsPage() {
cy.visit(getWorkflowsPageUrl());
}

View file

@ -1,16 +1,15 @@
import { getCanvasNodes } from '../composables/workflow';
import {
SCHEDULE_TRIGGER_NODE_NAME,
CODE_NODE_NAME,
SET_NODE_NAME,
EDIT_FIELDS_SET_NODE_NAME,
MANUAL_TRIGGER_NODE_NAME,
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
} from '../constants';
import { MessageBox as MessageBoxClass } from '../pages/modals/message-box';
import { NDV } from '../pages/ndv';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
// Suite-specific constants
const CODE_NODE_NEW_NAME = 'Something else';
const WorkflowPage = new WorkflowPageClass();
const messageBox = new MessageBoxClass();
const ndv = new NDV();
@ -20,40 +19,6 @@ describe('Undo/Redo', () => {
WorkflowPage.actions.visit();
});
// FIXME: Canvas V2: Fix redo connections
it('should undo/redo adding node in the middle', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeBetweenNodes(
SCHEDULE_TRIGGER_NODE_NAME,
CODE_NODE_NAME,
SET_NODE_NAME,
);
WorkflowPage.actions.zoomToFit();
WorkflowPage.getters.canvasNodeByName('Code').then(($codeNode) => {
const cssLeft = parseInt($codeNode.css('left'));
const cssTop = parseInt($codeNode.css('top'));
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 1);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
WorkflowPage.actions.hitRedo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
WorkflowPage.actions.hitRedo();
WorkflowPage.getters.canvasNodes().should('have.have.length', 3);
WorkflowPage.getters.nodeConnections().should('have.length', 2);
// Last node should be added back to original position
WorkflowPage.getters
.canvasNodeByName('Code')
.should('have.css', 'left', cssLeft + 'px')
.should('have.css', 'top', cssTop + 'px');
});
});
it('should undo/redo deleting node using context menu', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
@ -115,34 +80,60 @@ describe('Undo/Redo', () => {
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
// FIXME: Canvas V2: Fix moving of nodes via e2e tests
it('should undo/redo moving nodes', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).then(($node) => {
const initialPosition = $node.position();
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], { clickToFinish: true });
WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).then(($node) => {
const cssLeft = parseInt($node.css('left'));
const cssTop = parseInt($node.css('top'));
expect(cssLeft).to.be.greaterThan(initialPosition.left);
expect(cssTop).to.be.greaterThan(initialPosition.top);
});
WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.hitUndo();
WorkflowPage.getters
.canvasNodeByName(CODE_NODE_NAME)
.should('have.css', 'left', `${initialPosition.left}px`)
.should('have.css', 'top', `${initialPosition.top}px`);
WorkflowPage.actions.hitRedo();
WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).then(($node) => {
const cssLeft = parseInt($node.css('left'));
const cssTop = parseInt($node.css('top'));
expect(cssLeft).to.be.greaterThan(initialPosition.left);
expect(cssTop).to.be.greaterThan(initialPosition.top);
getCanvasNodes()
.last()
.then(($node) => {
const { x: x1, y: y1 } = $node[0].getBoundingClientRect();
cy.ifCanvasVersion(
() => {
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], {
clickToFinish: true,
});
},
() => {
cy.drag(getCanvasNodes().last(), [50, 150], {
realMouse: true,
abs: true,
});
},
);
getCanvasNodes()
.last()
.then(($node) => {
const { x: x2, y: y2 } = $node[0].getBoundingClientRect();
expect(x2).to.be.greaterThan(x1);
expect(y2).to.be.greaterThan(y1);
});
WorkflowPage.actions.hitUndo();
getCanvasNodes()
.last()
.then(($node) => {
const { x: x3, y: y3 } = $node[0].getBoundingClientRect();
expect(x3).to.equal(x1);
expect(y3).to.equal(y1);
});
WorkflowPage.actions.hitRedo();
getCanvasNodes()
.last()
.then(($node) => {
const { x: x4, y: y4 } = $node[0].getBoundingClientRect();
expect(x4).to.be.greaterThan(x1);
expect(y4).to.be.greaterThan(y1);
});
});
});
});
it('should undo/redo deleting a connection using context menu', () => {
@ -155,17 +146,6 @@ describe('Undo/Redo', () => {
WorkflowPage.actions.hitRedo();
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
// FIXME: Canvas V2: Fix disconnecting by moving
it('should undo/redo deleting a connection by moving it away', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.drag('.rect-input-endpoint.jtk-endpoint-connected', [0, -100]);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.nodeConnections().should('have.length', 1);
WorkflowPage.actions.hitRedo();
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
it('should undo/redo disabling a node using context menu', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
@ -204,23 +184,6 @@ describe('Undo/Redo', () => {
WorkflowPage.getters.disabledNodes().should('have.length', 2);
});
// FIXME: Canvas V2: Fix undo renaming node
it('should undo/redo renaming node using keyboard shortcut', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.canvasNodes().last().click();
cy.get('body').trigger('keydown', { key: 'F2' });
cy.get('.rename-prompt').should('be.visible');
cy.get('body').type(CODE_NODE_NEW_NAME);
cy.get('body').type('{enter}');
WorkflowPage.actions.hitUndo();
cy.get('body').type('{esc}');
WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).should('exist');
WorkflowPage.actions.hitRedo();
cy.get('body').type('{esc}');
WorkflowPage.getters.canvasNodeByName(CODE_NODE_NEW_NAME).should('exist');
});
it('should undo/redo duplicating a node', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
@ -243,77 +206,6 @@ describe('Undo/Redo', () => {
});
});
// FIXME: Canvas V2: Figure out why moving doesn't work from e2e
it('should undo/redo multiple steps', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
// WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.zoomToFit();
// Disable last node
WorkflowPage.getters.canvasNodes().last().click();
WorkflowPage.actions.hitDisableNodeShortcut();
// Move first one
WorkflowPage.actions
.getNodePosition(WorkflowPage.getters.canvasNodes().first())
.then((initialPosition) => {
WorkflowPage.getters.canvasNodes().first().click();
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], {
clickToFinish: true,
});
WorkflowPage.getters
.canvasNodes()
.first()
.then(($node) => {
const cssLeft = parseInt($node.css('left'));
const cssTop = parseInt($node.css('top'));
expect(cssLeft).to.be.greaterThan(initialPosition.left);
expect(cssTop).to.be.greaterThan(initialPosition.top);
});
// Delete the set node
WorkflowPage.getters.canvasNodeByName(EDIT_FIELDS_SET_NODE_NAME).click().click();
cy.get('body').type('{backspace}');
// First undo: Should return deleted node
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.canvasNodes().should('have.length', 4);
WorkflowPage.getters.nodeConnections().should('have.length', 3);
// Second undo: Should move first node to it's original position
WorkflowPage.actions.hitUndo();
WorkflowPage.getters
.canvasNodes()
.first()
.should('have.css', 'left', `${initialPosition.left}px`)
.should('have.css', 'top', `${initialPosition.top}px`);
// Third undo: Should enable last node
WorkflowPage.actions.hitUndo();
WorkflowPage.getters.disabledNodes().should('have.length', 0);
// First redo: Should disable last node
WorkflowPage.actions.hitRedo();
WorkflowPage.getters.disabledNodes().should('have.length', 1);
// Second redo: Should move the first node
WorkflowPage.actions.hitRedo();
WorkflowPage.getters
.canvasNodes()
.first()
.then(($node) => {
const cssLeft = parseInt($node.css('left'));
const cssTop = parseInt($node.css('top'));
expect(cssLeft).to.be.greaterThan(initialPosition.left);
expect(cssTop).to.be.greaterThan(initialPosition.top);
});
// Third redo: Should delete the Set node
WorkflowPage.actions.hitRedo();
WorkflowPage.getters.canvasNodes().should('have.length', 3);
WorkflowPage.getters.nodeConnections().should('have.length', 2);
});
});
it('should be able to copy and paste pinned data nodes in workflows with dynamic Switch node', () => {
cy.fixture('Test_workflow_form_switch.json').then((data) => {
cy.get('body').paste(JSON.stringify(data));

View file

@ -129,7 +129,7 @@ describe('Inline expression editor', () => {
// Run workflow
ndv.actions.close();
WorkflowPage.actions.executeNode('No Operation', { anchor: 'topLeft' });
WorkflowPage.actions.executeNode('No Operation, do nothing', { anchor: 'topLeft' });
WorkflowPage.actions.openNode('Hacker News');
WorkflowPage.actions.openInlineExpressionEditor();

View file

@ -4,9 +4,9 @@ import {
CODE_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
EDIT_FIELDS_SET_NODE_NAME,
IF_NODE_NAME,
HTTP_REQUEST_NODE_NAME,
} from './../constants';
import { getCanvasPane } from '../composables/workflow';
import { successToast } from '../pages/notifications';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
@ -16,64 +16,12 @@ describe('Canvas Actions', () => {
WorkflowPage.actions.visit();
});
// FIXME: Canvas V2: Missing execute button if no nodes
it('should render canvas', () => {
WorkflowPage.getters.nodeViewRoot().should('be.visible');
WorkflowPage.getters.canvasPlusButton().should('be.visible');
WorkflowPage.getters.zoomToFitButton().should('be.visible');
WorkflowPage.getters.zoomInButton().should('be.visible');
WorkflowPage.getters.zoomOutButton().should('be.visible');
WorkflowPage.getters.executeWorkflowButton().should('be.visible');
});
// FIXME: Canvas V2: Fix changing of connection
it('should connect and disconnect a simple node', () => {
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
WorkflowPage.getters.nodeViewBackground().click(600, 200, { force: true });
WorkflowPage.getters.nodeConnections().should('have.length', 1);
WorkflowPage.getters.nodeViewBackground().click(600, 400, { force: true });
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
// Change connection from Set to Set1
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('input', EDIT_FIELDS_SET_NODE_NAME),
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
);
WorkflowPage.getters
.getConnectionBetweenNodes(MANUAL_TRIGGER_NODE_DISPLAY_NAME, `${EDIT_FIELDS_SET_NODE_NAME}1`)
.should('be.visible');
WorkflowPage.getters.nodeConnections().should('have.length', 1);
// Disconnect Set1
cy.drag(
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
[-200, 100],
);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
it('should add first step', () => {
WorkflowPage.getters.canvasPlusButton().should('be.visible');
WorkflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodes().should('have.length', 1);
});
it('should add a node via plus endpoint drag', () => {
WorkflowPage.getters.canvasPlusButton().should('be.visible');
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME, true);
cy.drag(
WorkflowPage.getters.getEndpointSelector('plus', SCHEDULE_TRIGGER_NODE_NAME),
[100, 100],
);
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
WorkflowPage.actions.addNodeToCanvas(IF_NODE_NAME, false);
WorkflowPage.getters.nodeViewBackground().click({ force: true });
});
it('should add a connected node using plus endpoint', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodePlusEndpointByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
@ -116,7 +64,7 @@ describe('Canvas Actions', () => {
it('should add disconnected node if nothing is selected', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
// Deselect nodes
WorkflowPage.getters.nodeView().click({ force: true });
getCanvasPane().click({ force: true });
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
@ -166,15 +114,6 @@ describe('Canvas Actions', () => {
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
// FIXME: Canvas V2: Fix disconnecting of connection by dragging it
it('should delete a connection by moving it away from endpoint', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.drag(WorkflowPage.getters.getEndpointSelector('input', CODE_NODE_NAME), [0, -100]);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
describe('Node hover actions', () => {
it('should execute node', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
@ -184,7 +123,11 @@ describe('Canvas Actions', () => {
.last()
.findChildByTestId('execute-node-button')
.click({ force: true });
successToast().should('have.length', 1);
WorkflowPage.actions.executeNode(CODE_NODE_NAME);
successToast().should('have.length', 2);
successToast().should('contain.text', 'Node executed successfully');
});
@ -235,7 +178,6 @@ describe('Canvas Actions', () => {
WorkflowPage.getters.selectedNodes().should('have.length', 0);
});
// FIXME: Canvas V2: Selection via arrow keys is broken
it('should select nodes using arrow keys', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
@ -259,7 +201,6 @@ describe('Canvas Actions', () => {
);
});
// FIXME: Canvas V2: Selection via shift and arrow keys is broken
it('should select nodes using shift and arrow keys', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
@ -268,31 +209,4 @@ describe('Canvas Actions', () => {
cy.get('body').type('{shift}', { release: false }).type('{leftArrow}');
WorkflowPage.getters.selectedNodes().should('have.length', 2);
});
// FIXME: Canvas V2: Fix select & deselect
it('should not break lasso selection when dragging node action buttons', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters
.canvasNodes()
.last()
.findChildByTestId('execute-node-button')
.as('executeNodeButton');
cy.drag('@executeNodeButton', [200, 200]);
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
});
// FIXME: Canvas V2: Fix select & deselect
it('should not break lasso selection with multiple clicks on node action buttons', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
WorkflowPage.getters.canvasNodes().last().as('lastNode');
cy.get('@lastNode').findChildByTestId('execute-node-button').as('executeNodeButton');
for (let i = 0; i < 20; i++) {
cy.get('@lastNode').realHover();
cy.get('@executeNodeButton').should('be.visible');
cy.get('@executeNodeButton').realTouch();
cy.getByTestId('execute-workflow-button').realHover();
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
}
});
});

View file

@ -7,9 +7,17 @@ import {
SWITCH_NODE_NAME,
MERGE_NODE_NAME,
} from './../constants';
import {
clickContextMenuAction,
getCanvasNodeByName,
getCanvasNodes,
getConnectionBySourceAndTarget,
getConnectionLabelBySourceAndTarget,
getOutputPlusHandle,
openContextMenu,
} from '../composables/workflow';
import { NDV, WorkflowExecutionsTab } from '../pages';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
import { isCanvasV2 } from '../utils/workflowUtils';
const WorkflowPage = new WorkflowPageClass();
const ExecutionsTab = new WorkflowExecutionsTab();
@ -20,8 +28,6 @@ const ZOOM_IN_X2_FACTOR = 1.5625; // Zoom in factor after two clicks
const ZOOM_OUT_X1_FACTOR = 0.8;
const ZOOM_OUT_X2_FACTOR = 0.64;
const PINCH_ZOOM_IN_FACTOR = 1.05702;
const PINCH_ZOOM_OUT_FACTOR = 0.946058;
const RENAME_NODE_NAME = 'Something else';
const RENAME_NODE_NAME2 = 'Something different';
@ -41,27 +47,52 @@ describe('Canvas Node Manipulation and Navigation', () => {
NDVDialog.actions.close();
for (let i = 0; i < desiredOutputs; i++) {
WorkflowPage.getters.canvasNodePlusEndpointByName(SWITCH_NODE_NAME, i).click({ force: true });
cy.ifCanvasVersion(
() => {
WorkflowPage.getters
.canvasNodePlusEndpointByName(SWITCH_NODE_NAME, i)
.click({ force: true });
},
() => {
getOutputPlusHandle(SWITCH_NODE_NAME).eq(0).click();
},
);
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, false);
WorkflowPage.actions.zoomToFit();
}
WorkflowPage.getters.nodeViewBackground().click({ force: true });
WorkflowPage.getters.canvasNodePlusEndpointByName(`${EDIT_FIELDS_SET_NODE_NAME}3`).click();
cy.ifCanvasVersion(
() => {
WorkflowPage.getters.canvasNodePlusEndpointByName(`${EDIT_FIELDS_SET_NODE_NAME}3`).click();
},
() => {
getOutputPlusHandle(`${EDIT_FIELDS_SET_NODE_NAME}3`).click();
},
);
WorkflowPage.actions.addNodeToCanvas(SWITCH_NODE_NAME, false);
WorkflowPage.actions.saveWorkflowOnButtonClick();
cy.reload();
cy.waitForLoad();
// Make sure outputless switch was connected correctly
WorkflowPage.getters
.getConnectionBetweenNodes(`${EDIT_FIELDS_SET_NODE_NAME}3`, `${SWITCH_NODE_NAME}1`)
.should('exist');
cy.ifCanvasVersion(
() => {
WorkflowPage.getters
.getConnectionBetweenNodes(`${EDIT_FIELDS_SET_NODE_NAME}3`, `${SWITCH_NODE_NAME}1`)
.should('exist');
},
() => {
getConnectionBySourceAndTarget(
`${EDIT_FIELDS_SET_NODE_NAME}3`,
`${SWITCH_NODE_NAME}1`,
).should('exist');
},
);
// Make sure all connections are there after reload
for (let i = 0; i < desiredOutputs; i++) {
const setName = `${EDIT_FIELDS_SET_NODE_NAME}${i > 0 ? i : ''}`;
WorkflowPage.getters
.getConnectionBetweenNodes(`${SWITCH_NODE_NAME}`, setName)
.should('exist');
getConnectionBySourceAndTarget(`${SWITCH_NODE_NAME}`, setName).should('exist');
}
});
@ -84,14 +115,29 @@ describe('Canvas Node Manipulation and Navigation', () => {
);
// Connect Set1 and Set2 to merge
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('plus', EDIT_FIELDS_SET_NODE_NAME),
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 0),
);
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('plus', `${EDIT_FIELDS_SET_NODE_NAME}1`),
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 1),
cy.ifCanvasVersion(
() => {
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('plus', EDIT_FIELDS_SET_NODE_NAME),
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 0),
);
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('plus', `${EDIT_FIELDS_SET_NODE_NAME}1`),
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 1),
);
},
() => {
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('output', EDIT_FIELDS_SET_NODE_NAME),
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 0),
);
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('output', `${EDIT_FIELDS_SET_NODE_NAME}1`),
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 1),
);
},
);
const checkConnections = () => {
WorkflowPage.getters
.getConnectionBetweenNodes(
@ -117,10 +163,22 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.actions.executeWorkflow();
WorkflowPage.getters.stopExecutionButton().should('not.exist');
// Make sure all connections are there after save & reload
WorkflowPage.actions.saveWorkflowOnButtonClick();
cy.reload();
cy.waitForLoad();
checkConnections();
WorkflowPage.actions.executeWorkflow();
WorkflowPage.getters.stopExecutionButton().should('not.exist');
// If the merged set nodes are connected and executed correctly, there should be 2 items in the output of merge node
cy.ifCanvasVersion(
() => cy.get('[data-label="2 items"]').should('be.visible'),
() => cy.getByTestId('canvas-node-output-handle').contains('2 items').should('be.visible'),
() =>
getConnectionLabelBySourceAndTarget(`${EDIT_FIELDS_SET_NODE_NAME}1`, MERGE_NODE_NAME)
.contains('2 items')
.should('be.visible'),
);
});
@ -144,7 +202,10 @@ describe('Canvas Node Manipulation and Navigation', () => {
cy.ifCanvasVersion(
() => cy.get('.plus-draggable-endpoint').should('have.class', 'ep-success'),
() => cy.getByTestId('canvas-handle-plus').should('have.attr', 'data-plus-type', 'success'),
() =>
cy
.getByTestId('canvas-handle-plus-wrapper')
.should('have.attr', 'data-plus-type', 'success'),
);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
@ -212,8 +273,8 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500);
WorkflowPage.actions.selectAllFromContextMenu();
WorkflowPage.actions.openContextMenu();
WorkflowPage.actions.contextMenuAction('delete');
openContextMenu();
clickContextMenuAction('delete');
WorkflowPage.getters.canvasNodes().should('have.length', 0);
});
@ -228,41 +289,43 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500);
WorkflowPage.actions.selectAllFromContextMenu();
WorkflowPage.actions.openContextMenu();
WorkflowPage.actions.contextMenuAction('delete');
openContextMenu();
clickContextMenuAction('delete');
WorkflowPage.getters.canvasNodes().should('have.length', 0);
});
// FIXME: Canvas V2: Figure out how to test moving of the node
it('should move node', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.zoomToFit();
WorkflowPage.getters
.canvasNodes()
getCanvasNodes()
.last()
.then(($node) => {
const { left, top } = $node.position();
const { x: x1, y: y1 } = $node[0].getBoundingClientRect();
if (isCanvasV2()) {
cy.drag('.vue-flow__node', [300, 300], {
realMouse: true,
});
} else {
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], {
clickToFinish: true,
});
}
cy.ifCanvasVersion(
() => {
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], {
clickToFinish: true,
});
},
() => {
cy.drag(getCanvasNodes().last(), [50, 150], {
realMouse: true,
abs: true,
});
},
);
WorkflowPage.getters
.canvasNodes()
getCanvasNodes()
.last()
.then(($node) => {
const { left: newLeft, top: newTop } = $node.position();
expect(newLeft).to.be.greaterThan(left);
expect(newTop).to.be.greaterThan(top);
const { x: x2, y: y2 } = $node[0].getBoundingClientRect();
expect(x2).to.be.greaterThan(x1);
expect(y2).to.be.greaterThan(y1);
});
});
});
@ -304,26 +367,6 @@ describe('Canvas Node Manipulation and Navigation', () => {
zoomAndCheck('zoomOut', ZOOM_OUT_X2_FACTOR);
});
it('should zoom using scroll or pinch gesture', () => {
WorkflowPage.actions.pinchToZoom(1, 'zoomIn');
// V2 Canvas is using the same zoom factor for both pinch and scroll
cy.ifCanvasVersion(
() => checkZoomLevel(PINCH_ZOOM_IN_FACTOR),
() => checkZoomLevel(ZOOM_IN_X1_FACTOR),
);
WorkflowPage.actions.pinchToZoom(1, 'zoomOut');
checkZoomLevel(1); // Zoom in 1x + Zoom out 1x should reset to default (=1)
WorkflowPage.actions.pinchToZoom(1, 'zoomOut');
cy.ifCanvasVersion(
() => checkZoomLevel(PINCH_ZOOM_OUT_FACTOR),
() => checkZoomLevel(ZOOM_OUT_X1_FACTOR),
);
});
it('should reset zoom', () => {
WorkflowPage.getters.resetZoomButton().should('not.exist');
WorkflowPage.getters.zoomInButton().click();
@ -369,7 +412,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.actions.hitDisableNodeShortcut();
WorkflowPage.getters.disabledNodes().should('have.length', 0);
WorkflowPage.actions.deselectAll();
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
getCanvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.hitDisableNodeShortcut();
WorkflowPage.getters.disabledNodes().should('have.length', 1);
WorkflowPage.actions.hitSelectAll();
@ -378,19 +421,19 @@ describe('Canvas Node Manipulation and Navigation', () => {
// Context menu
WorkflowPage.actions.hitSelectAll();
WorkflowPage.actions.openContextMenu();
openContextMenu();
WorkflowPage.actions.contextMenuAction('toggle_activation');
WorkflowPage.getters.disabledNodes().should('have.length', 0);
WorkflowPage.actions.openContextMenu();
openContextMenu();
WorkflowPage.actions.contextMenuAction('toggle_activation');
WorkflowPage.getters.disabledNodes().should('have.length', 2);
WorkflowPage.actions.deselectAll();
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.openContextMenu();
openContextMenu();
WorkflowPage.actions.contextMenuAction('toggle_activation');
WorkflowPage.getters.disabledNodes().should('have.length', 1);
WorkflowPage.actions.hitSelectAll();
WorkflowPage.actions.openContextMenu();
openContextMenu();
WorkflowPage.actions.contextMenuAction('toggle_activation');
WorkflowPage.getters.disabledNodes().should('have.length', 2);
});
@ -466,7 +509,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
});
// FIXME: Canvas V2: Credentials should show issue on the first open
it('should remove unknown credentials on pasting workflow', () => {
cy.fixture('workflow-with-unknown-credentials.json').then((data) => {
cy.get('body').paste(JSON.stringify(data));
@ -478,35 +521,4 @@ describe('Canvas Node Manipulation and Navigation', () => {
NDVDialog.actions.close();
});
});
// FIXME: Canvas V2: Unknown nodes should still render connection endpoints
it('should render connections correctly if unkown nodes are present', () => {
const unknownNodeName = 'Unknown node';
cy.createFixtureWorkflow('workflow-with-unknown-nodes.json', 'Unknown nodes');
WorkflowPage.getters.canvasNodeByName(`${unknownNodeName} 1`).should('exist');
WorkflowPage.getters.canvasNodeByName(`${unknownNodeName} 2`).should('exist');
WorkflowPage.actions.zoomToFit();
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('plus', `${unknownNodeName} 1`),
WorkflowPage.getters.getEndpointSelector('input', EDIT_FIELDS_SET_NODE_NAME),
);
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('plus', `${unknownNodeName} 2`),
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
);
WorkflowPage.actions.executeWorkflow();
cy.contains('Unrecognized node type').should('be.visible');
WorkflowPage.actions.deselectAll();
WorkflowPage.actions.deleteNodeFromContextMenu(`${unknownNodeName} 1`);
WorkflowPage.actions.deleteNodeFromContextMenu(`${unknownNodeName} 2`);
WorkflowPage.actions.executeWorkflow();
cy.contains('Unrecognized node type').should('not.exist');
});
});

View file

@ -1,6 +1,3 @@
import { nanoid } from 'nanoid';
import { simpleWebhookCall, waitForWebhook } from './16-webhook-node.cy';
import {
HTTP_REQUEST_NODE_NAME,
MANUAL_TRIGGER_NODE_NAME,
@ -109,36 +106,6 @@ describe('Data pinning', () => {
ndv.getters.outputTbodyCell(1, 0).should('include.text', 1);
});
it('Should be able to pin data from canvas (context menu or shortcut)', () => {
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger');
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
workflowPage.actions.openContextMenu(EDIT_FIELDS_SET_NODE_NAME, { method: 'overflow-button' });
workflowPage.getters
.contextMenuAction('toggle_pin')
.parent()
.should('have.class', 'is-disabled');
cy.get('body').type('{esc}');
// Unpin using context menu
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
ndv.actions.setPinnedData([{ test: 1 }]);
ndv.actions.close();
workflowPage.actions.pinNode(EDIT_FIELDS_SET_NODE_NAME);
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
ndv.getters.nodeOutputHint().should('exist');
ndv.actions.close();
// Unpin using shortcut
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
ndv.actions.setPinnedData([{ test: 1 }]);
ndv.actions.close();
workflowPage.getters.canvasNodeByName(EDIT_FIELDS_SET_NODE_NAME).click();
workflowPage.actions.hitPinNodeShortcut();
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
ndv.getters.nodeOutputHint().should('exist');
});
it('Should show an error when maximum pin data size is exceeded', () => {
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger');
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true);
@ -217,32 +184,6 @@ describe('Data pinning', () => {
);
});
it('should show pinned data tooltip', () => {
const { callEndpoint } = simpleWebhookCall({
method: 'GET',
webhookPath: nanoid(),
executeNow: false,
});
ndv.actions.close();
workflowPage.actions.executeWorkflow();
cy.wait(waitForWebhook);
// hide other visible popper on workflow execute button
workflowPage.getters.canvasNodes().eq(0).click();
callEndpoint((response) => {
expect(response.status).to.eq(200);
getVisiblePopper().should('have.length', 1);
getVisiblePopper()
.eq(0)
.should(
'have.text',
'You can pin this output instead of waiting for a test event. Open node to do so.',
);
});
});
it('should not show pinned data tooltip', () => {
cy.createFixtureWorkflow('Pinned_webhook_node.json', 'Test');
workflowPage.actions.executeWorkflow();

View file

@ -62,12 +62,14 @@ describe('n8n Form Trigger', () => {
getVisibleSelect().contains('Dropdown').click();
cy.contains('button', 'Add Field Option').click();
cy.contains('label', 'Field Options')
.parent()
.parent()
.nextAll()
.find('[data-test-id="parameter-input-field"]')
.eq(0)
.type('Option 1');
cy.contains('label', 'Field Options')
.parent()
.parent()
.nextAll()
.find('[data-test-id="parameter-input-field"]')

View file

@ -1,5 +1,6 @@
import { nanoid } from 'nanoid';
import { simpleWebhookCall, waitForWebhook } from '../composables/webhooks';
import { BACKEND_BASE_URL, EDIT_FIELDS_SET_NODE_NAME } from '../constants';
import { WorkflowPage, NDV, CredentialsModal } from '../pages';
import { cowBase64 } from '../support/binaryTestFiles';
@ -9,81 +10,6 @@ const workflowPage = new WorkflowPage();
const ndv = new NDV();
const credentialsModal = new CredentialsModal();
export const waitForWebhook = 500;
interface SimpleWebhookCallOptions {
method: string;
webhookPath: string;
responseCode?: number;
respondWith?: string;
executeNow?: boolean;
responseData?: string;
authentication?: string;
}
export const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
const {
authentication,
method,
webhookPath,
responseCode,
respondWith,
responseData,
executeNow = true,
} = options;
workflowPage.actions.addInitialNodeToCanvas('Webhook');
workflowPage.actions.openNode('Webhook');
cy.getByTestId('parameter-input-httpMethod').click();
getVisibleSelect().find('.option-headline').contains(method).click();
cy.getByTestId('parameter-input-path')
.find('.parameter-input')
.find('input')
.clear()
.type(webhookPath);
if (authentication) {
cy.getByTestId('parameter-input-authentication').click();
getVisibleSelect().find('.option-headline').contains(authentication).click();
}
if (responseCode) {
cy.get('.param-options').click();
getVisibleSelect().contains('Response Code').click();
cy.get('.parameter-item-wrapper > .parameter-input-list-wrapper').children().click();
getVisibleSelect().contains('201').click();
}
if (respondWith) {
cy.getByTestId('parameter-input-responseMode').click();
getVisibleSelect().find('.option-headline').contains(respondWith).click();
}
if (responseData) {
cy.getByTestId('parameter-input-responseData').click();
getVisibleSelect().find('.option-headline').contains(responseData).click();
}
const callEndpoint = (cb: (response: Cypress.Response<unknown>) => void) => {
cy.request(method, `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then(cb);
};
if (executeNow) {
ndv.actions.execute();
cy.wait(waitForWebhook);
callEndpoint((response) => {
expect(response.status).to.eq(200);
ndv.getters.outputPanel().contains('headers');
});
}
return {
callEndpoint,
};
};
describe('Webhook Trigger node', () => {
beforeEach(() => {
workflowPage.actions.visit();
@ -250,7 +176,7 @@ describe('Webhook Trigger node', () => {
});
// add credentials
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.actions.fillCredentialsForm();
@ -293,7 +219,7 @@ describe('Webhook Trigger node', () => {
});
// add credentials
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.actions.fillCredentialsForm();

View file

@ -297,10 +297,9 @@ describe('Credential Usage in Cross Shared Workflows', () => {
workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Only the credential in this project (+ the 'Create new' option) should
// be in the dropdown
// Only the credential in this project should be in the dropdown
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length', 2);
getVisibleSelect().find('li').should('have.length', 1);
});
it('should only show credentials in their personal project for members', () => {
@ -325,10 +324,9 @@ describe('Credential Usage in Cross Shared Workflows', () => {
workflowsPage.actions.createWorkflowFromCard();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Only the own credential the shared one (+ the 'Create new' option)
// should be in the dropdown
// Only the own credential the shared one should be in the dropdown
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length', 3);
getVisibleSelect().find('li').should('have.length', 2);
});
it('should only show credentials in their personal project for members if the workflow was shared with them', () => {
@ -355,10 +353,9 @@ describe('Credential Usage in Cross Shared Workflows', () => {
workflowsPage.getters.workflowCardContent(workflowName).click();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Only the own credential the shared one (+ the 'Create new' option)
// should be in the dropdown
// Only the own credential the shared one should be in the dropdown
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length', 2);
getVisibleSelect().find('li').should('have.length', 1);
});
it("should show all credentials from all personal projects the workflow's been shared into for the global owner", () => {
@ -400,10 +397,9 @@ describe('Credential Usage in Cross Shared Workflows', () => {
workflowsPage.getters.workflowCardContent(workflowName).click();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
// Only the personal credentials of the workflow owner and the global owner
// should show up.
// Only the personal credentials of the workflow owner and the global owner should show up.
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length', 4);
getVisibleSelect().find('li').should('have.length', 3);
});
it('should show all personal credentials if the global owner owns the workflow', () => {
@ -421,6 +417,6 @@ describe('Credential Usage in Cross Shared Workflows', () => {
// Show all personal credentials
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.have.length', 2);
getVisibleSelect().find('li').should('have.have.length', 1);
});
});

View file

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

View file

@ -1,3 +1,11 @@
import { clickGetBackToCanvas, getNdvContainer, getOutputTableRow } from '../composables/ndv';
import {
clickExecuteWorkflowButton,
getExecuteWorkflowButton,
getNodeByName,
getZoomToFitButton,
openNode,
} from '../composables/workflow';
import { SCHEDULE_TRIGGER_NODE_NAME, EDIT_FIELDS_SET_NODE_NAME } from '../constants';
import { NDV, WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
import { clearNotifications, errorToast, successToast } from '../pages/notifications';
@ -214,89 +222,37 @@ describe('Execution', () => {
workflowPage.getters.clearExecutionDataButton().should('not.exist');
});
// FIXME: Canvas V2: Webhook should show waiting state but it doesn't
it('should test webhook workflow stop', () => {
cy.createFixtureWorkflow('Webhook_wait_set.json');
it('should test workflow with specific trigger node', () => {
cy.createFixtureWorkflow('Two_schedule_triggers.json');
// Check workflow buttons
workflowPage.getters.executeWorkflowButton().should('be.visible');
workflowPage.getters.clearExecutionDataButton().should('not.exist');
workflowPage.getters.stopExecutionButton().should('not.exist');
workflowPage.getters.stopExecutionWaitingForWebhookButton().should('not.exist');
getZoomToFitButton().click();
getExecuteWorkflowButton('Trigger A').should('not.be.visible');
getExecuteWorkflowButton('Trigger B').should('not.be.visible');
// Execute the workflow
workflowPage.getters.zoomToFitButton().click();
workflowPage.getters.executeWorkflowButton().click();
// Execute the workflow from trigger A
getNodeByName('Trigger A').realHover();
getExecuteWorkflowButton('Trigger A').should('be.visible');
getExecuteWorkflowButton('Trigger B').should('not.be.visible');
clickExecuteWorkflowButton('Trigger A');
// Check workflow buttons
workflowPage.getters.executeWorkflowButton().get('.n8n-spinner').should('be.visible');
workflowPage.getters.clearExecutionDataButton().should('not.exist');
workflowPage.getters.stopExecutionButton().should('not.exist');
workflowPage.getters.stopExecutionWaitingForWebhookButton().should('be.visible');
// Check the output
successToast().contains('Workflow executed successfully');
openNode('Edit Fields');
getOutputTableRow(1).should('include.text', 'Trigger A');
workflowPage.getters.canvasNodes().first().dblclick();
clickGetBackToCanvas();
getNdvContainer().should('not.be.visible');
ndv.getters.copyInput().click();
// Execute the workflow from trigger B
getNodeByName('Trigger B').realHover();
getExecuteWorkflowButton('Trigger A').should('not.be.visible');
getExecuteWorkflowButton('Trigger B').should('be.visible');
clickExecuteWorkflowButton('Trigger B');
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
ndv.getters.backToCanvas().click();
cy.readClipboard().then((url) => {
cy.request({
method: 'GET',
url,
}).then((resp) => {
expect(resp.status).to.eq(200);
});
});
successToast().should('be.visible');
clearNotifications();
workflowPage.getters.stopExecutionButton().click();
// Check canvas nodes after 1st step (workflow passed the manual trigger node
workflowPage.getters
.canvasNodeByName('Webhook')
.within(() => cy.get('.fa-check'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-check').should('not.exist'));
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-sync-alt'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('.fa-check').should('not.exist'));
// Check canvas nodes after workflow stopped
workflowPage.getters
.canvasNodeByName('Webhook')
.within(() => cy.get('.fa-check'))
.should('exist');
if (isCanvasV2()) {
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-sync-alt').should('not.exist'));
} else {
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-sync-alt').should('not.be.visible'));
}
workflowPage.getters
.canvasNodeByName('Set')
.within(() => cy.get('.fa-check').should('not.exist'));
successToast().should('be.visible');
// Clear execution data
workflowPage.getters.clearExecutionDataButton().should('be.visible');
workflowPage.getters.clearExecutionDataButton().click();
workflowPage.getters.clearExecutionDataButton().should('not.exist');
// Check the output
successToast().contains('Workflow executed successfully');
openNode('Edit Fields');
getOutputTableRow(1).should('include.text', 'Trigger B');
});
describe('execution preview', () => {
@ -312,8 +268,11 @@ describe('Execution', () => {
});
});
// FIXME: Canvas V2: Missing pinned states for `edge-label-wrapper`
describe('connections should be colored differently for pinned data', () => {
/**
* @TODO New Canvas: Different classes for pinned states on edges and nodes
*/
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
describe.skip('connections should be colored differently for pinned data', () => {
beforeEach(() => {
cy.createFixtureWorkflow('Schedule_pinned.json');
workflowPage.actions.deselectAll();
@ -634,45 +593,4 @@ describe('Execution', () => {
errorToast().should('contain', 'Problem in node Telegram');
});
it('should not show pinned data in production execution', () => {
cy.createFixtureWorkflow('Execution-pinned-data-check.json');
workflowPage.getters.zoomToFitButton().click();
cy.intercept('PATCH', '/rest/workflows/*').as('workflowActivate');
workflowPage.getters.activatorSwitch().click();
cy.wait('@workflowActivate');
cy.get('body').type('{esc}');
workflowPage.actions.openNode('Webhook');
cy.contains('label', 'Production URL').should('be.visible').click();
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
cy.get('.webhook-url').click();
ndv.getters.backToCanvas().click();
cy.readClipboard().then((url) => {
cy.request({
method: 'GET',
url,
}).then((resp) => {
expect(resp.status).to.eq(200);
});
});
cy.intercept('GET', '/rest/executions/*').as('getExecution');
executionsTab.actions.switchToExecutionsTab();
cy.wait('@getExecution');
executionsTab.getters
.workflowExecutionPreviewIframe()
.should('be.visible')
.its('0.contentDocument.body')
.should('not.be.empty')
.then(cy.wrap)
.find('.connection-run-items-label')
.filter(':contains("5 items")')
.should('have.length', 2);
});
});

View file

@ -31,7 +31,7 @@ function createNotionCredential() {
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME);
workflowPage.actions.openNode(NOTION_NODE_NAME);
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.actions.fillCredentialsForm();
cy.get('body').type('{esc}');
workflowPage.actions.deleteNode(NOTION_NODE_NAME);
@ -79,7 +79,7 @@ describe('Credentials', () => {
workflowPage.getters.canvasNodes().last().click();
cy.get('body').type('{enter}');
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2);
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
@ -99,7 +99,7 @@ describe('Credentials', () => {
cy.get('body').type('{enter}');
workflowPage.getters.nodeCredentialsSelect().click();
// Add oAuth credentials
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2);
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
@ -107,14 +107,13 @@ describe('Credentials', () => {
cy.get('.el-message-box').find('button').contains('Close').click();
workflowPage.getters.nodeCredentialsSelect().click();
// Add Service account credentials
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialsEditModal().should('be.visible');
credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2);
credentialsModal.getters.credentialAuthTypeRadioButtons().last().click();
credentialsModal.actions.fillCredentialsForm();
// Both (+ the 'Create new' option) should be in the dropdown
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').should('have.length.greaterThan', 2);
getVisibleSelect().find('li').should('have.length', 3);
});
it('should correctly render required and optional credentials', () => {
@ -130,13 +129,13 @@ describe('Credentials', () => {
workflowPage.getters.nodeCredentialsSelect().should('have.length', 2);
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect().find('li').contains('Create New Credential').click();
workflowPage.getters.nodeCredentialsCreateOption().first().click();
// This one should show auth type selector
credentialsModal.getters.credentialAuthTypeRadioButtons().should('have.length', 2);
cy.get('body').type('{esc}');
workflowPage.getters.nodeCredentialsSelect().last().click();
getVisibleSelect().find('li').contains('Create New Credential').click();
workflowPage.getters.nodeCredentialsCreateOption().last().click();
// This one should not show auth type selector
credentialsModal.getters.credentialsAuthTypeSelector().should('not.exist');
});
@ -148,7 +147,7 @@ describe('Credentials', () => {
workflowPage.getters.canvasNodes().last().click();
cy.get('body').type('{enter}');
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialsAuthTypeSelector().should('not.exist');
credentialsModal.actions.fillCredentialsForm();
workflowPage.getters
@ -164,7 +163,7 @@ describe('Credentials', () => {
workflowPage.getters.canvasNodes().last().click();
cy.get('body').type('{enter}');
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.actions.fillCredentialsForm();
workflowPage.getters
.nodeCredentialsSelect()
@ -189,7 +188,7 @@ describe('Credentials', () => {
workflowPage.getters.canvasNodes().last().click();
cy.get('body').type('{enter}');
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.actions.fillCredentialsForm();
workflowPage.getters.nodeCredentialsEditButton().click();
credentialsModal.getters.credentialsEditModal().should('be.visible');
@ -232,7 +231,7 @@ describe('Credentials', () => {
cy.getByTestId('credential-select').click();
cy.contains('Adalo API').click();
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.actions.fillCredentialsForm();
workflowPage.getters.nodeCredentialsEditButton().click();
credentialsModal.getters.credentialsEditModal().should('be.visible');
@ -296,7 +295,7 @@ describe('Credentials', () => {
workflowPage.getters.nodeCredentialsSelect().should('exist');
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.actions.fillCredentialsForm();
workflowPage.getters
.nodeCredentialsSelect()
@ -325,7 +324,7 @@ describe('Credentials', () => {
workflowPage.actions.addNodeToCanvas('Slack', true, true, 'Get a channel');
workflowPage.getters.nodeCredentialsSelect().should('exist');
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
nodeDetailsView.getters.copyInput().should('not.exist');
});

View file

@ -89,7 +89,7 @@ describe('Community and custom nodes in canvas', () => {
workflowPage.actions.addNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('E2E Node with native n8n credential', true, true);
workflowPage.getters.nodeCredentialsLabel().click();
cy.contains('Create New Credential').click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.editCredentialModal().should('be.visible');
credentialsModal.getters.editCredentialModal().should('contain.text', 'Notion API');
});
@ -98,7 +98,7 @@ describe('Community and custom nodes in canvas', () => {
workflowPage.actions.addNodeToCanvas('Manual');
workflowPage.actions.addNodeToCanvas('E2E Node with custom credential', true, true);
workflowPage.getters.nodeCredentialsLabel().click();
cy.contains('Create New Credential').click();
workflowPage.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.editCredentialModal().should('be.visible');
credentialsModal.getters.editCredentialModal().should('contain.text', 'Custom E2E Credential');
});

View file

@ -1,38 +0,0 @@
import { WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
const workflowPage = new WorkflowPageClass();
const executionsTab = new WorkflowExecutionsTab();
describe('ADO-2106 connections should be colored correctly for pinned data in executions preview', () => {
beforeEach(() => {
workflowPage.actions.visit();
});
beforeEach(() => {
cy.createFixtureWorkflow('Webhook_set_pinned.json');
workflowPage.actions.deselectAll();
workflowPage.getters.zoomToFitButton().click();
workflowPage.getters.getConnectionBetweenNodes('Webhook', 'Set').should('have.class', 'pinned');
});
it('should color connections for pinned data nodes for manual executions', () => {
workflowPage.actions.executeWorkflow();
executionsTab.actions.switchToExecutionsTab();
executionsTab.getters.successfulExecutionListItems().should('have.length', 1);
executionsTab.getters
.workflowExecutionPreviewIframe()
.should('be.visible')
.its('0.contentDocument.body')
.should('not.be.empty')
.then(cy.wrap)
.find('.jtk-connector[data-source-node="Webhook"][data-target-node="Set"]')
.should('have.class', 'success')
.should('have.class', 'has-run')
.should('have.class', 'pinned');
});
});

View file

@ -1,135 +0,0 @@
import { WorkflowPage, NDV } from '../pages';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
describe('ADO-2111 expressions should support pinned data', () => {
beforeEach(() => {
workflowPage.actions.visit();
});
it('supports pinned data in expressions unexecuted and executed parent nodes', () => {
cy.createFixtureWorkflow('Test_workflow_pinned_data_in_expressions.json', 'Expressions');
// test previous node unexecuted
workflowPage.actions.openNode('NotPinnedWithExpressions');
ndv.getters
.parameterExpressionPreview('value')
.eq(0)
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
ndv.getters
.parameterExpressionPreview('value')
.eq(1)
.should('contain.text', '0,0\nJoe\n\nJoe\n\nJoe\n\nJoe\nJoe');
// test can resolve correctly based on item
ndv.actions.switchInputMode('Table');
ndv.getters.inputTableRow(2).realHover();
cy.wait(50);
ndv.getters
.parameterExpressionPreview('value')
.eq(0)
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
ndv.getters
.parameterExpressionPreview('value')
.eq(1)
.should('contain.text', '0,1\nJoan\n\nJoan\n\nJoan\n\nJoan\nJoan');
// test previous node executed
ndv.actions.execute();
ndv.getters.inputTableRow(1).realHover();
cy.wait(50);
ndv.getters
.parameterExpressionPreview('value')
.eq(0)
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
ndv.getters
.parameterExpressionPreview('value')
.eq(1)
.should('contain.text', '0,0\nJoe\n\nJoe\n\nJoe\n\nJoe\nJoe');
ndv.getters.inputTableRow(2).realHover();
cy.wait(50);
ndv.getters
.parameterExpressionPreview('value')
.eq(0)
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
ndv.getters
.parameterExpressionPreview('value')
.eq(1)
.should('contain.text', '0,1\nJoan\n\nJoan\n\nJoan\n\nJoan\nJoan');
// check it resolved correctly on the backend
ndv.getters
.outputTbodyCell(1, 0)
.should('contain.text', 'Joe\\nJoe\\nJoan\\nJoan\\nJoe\\nJoan\\n\\nJoe\\nJoan\\n\\nJoe');
ndv.getters
.outputTbodyCell(2, 0)
.should('contain.text', 'Joe\\nJoe\\nJoan\\nJoan\\nJoe\\nJoan\\n\\nJoe\\nJoan\\n\\nJoe');
ndv.getters
.outputTbodyCell(1, 1)
.should('contain.text', '0,0\\nJoe\\n\\nJoe\\n\\nJoe\\n\\nJoe\\nJoe');
ndv.getters
.outputTbodyCell(2, 1)
.should('contain.text', '0,1\\nJoan\\n\\nJoan\\n\\nJoan\\n\\nJoan\\nJoan');
});
it('resets expressions after node is unpinned', () => {
cy.createFixtureWorkflow('Test_workflow_pinned_data_in_expressions.json', 'Expressions');
// test previous node unexecuted
workflowPage.actions.openNode('NotPinnedWithExpressions');
ndv.getters
.parameterExpressionPreview('value')
.eq(0)
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
ndv.getters
.parameterExpressionPreview('value')
.eq(1)
.should('contain.text', '0,0\nJoe\n\nJoe\n\nJoe\n\nJoe\nJoe');
ndv.actions.close();
// unpin pinned node
workflowPage.getters
.canvasNodeByName('PinnedSet')
.eq(0)
.find('.node-pin-data-icon')
.should('exist');
workflowPage.getters.canvasNodeByName('PinnedSet').eq(0).click();
workflowPage.actions.hitPinNodeShortcut();
workflowPage.getters
.canvasNodeByName('PinnedSet')
.eq(0)
.find('.node-pin-data-icon')
.should('not.exist');
workflowPage.actions.openNode('NotPinnedWithExpressions');
ndv.getters.nodeParameters().find('parameter-expression-preview-value').should('not.exist');
ndv.getters.parameterInput('value').eq(0).click();
ndv.getters
.inlineExpressionEditorOutput()
.should(
'have.text',
'[Execute node PinnedSet for preview][Execute node PinnedSet for preview][Execute node PinnedSet for preview][Execute node PinnedSet for preview][Execute node PinnedSet for preview][Execute node PinnedSet for preview][Execute previous nodes for preview][Execute previous nodes for preview][undefined]',
);
// close open expression
ndv.getters.inputLabel().eq(0).click();
ndv.getters.parameterInput('value').eq(1).click();
ndv.getters
.inlineExpressionEditorOutput()
.should(
'have.text',
'0,0[Execute node PinnedSet for preview][Execute node PinnedSet for preview][Execute previous nodes for preview][Execute previous nodes for preview][Execute previous nodes for preview]',
);
});
});

View file

@ -65,8 +65,11 @@ describe('Variables', () => {
const editingRow = variablesPage.getters.variablesEditableRows().eq(0);
variablesPage.actions.setRowValue(editingRow, 'key', key);
variablesPage.actions.setRowValue(editingRow, 'value', value);
editingRow.should('contain', 'This field may contain only letters');
variablesPage.getters.editableRowSaveButton(editingRow).should('be.disabled');
variablesPage.actions.saveRowEditing(editingRow);
variablesPage.getters
.variablesEditableRows()
.eq(0)
.should('contain', 'This field may contain only letters');
variablesPage.actions.cancelRowEditing(editingRow);
variablesPage.getters.variablesRows().should('have.length', 3);

View file

@ -118,6 +118,15 @@ describe('NDV', () => {
ndv.actions.switchInputMode('Table');
ndv.actions.switchOutputMode('Table');
// Start from linked state
ndv.getters.outputLinkRun().then(($el) => {
const classList = Array.from($el[0].classList);
if (!classList.includes('linked')) {
ndv.actions.toggleOutputRunLinking();
ndv.getters.inputTbodyCell(1, 0).click(); // remove tooltip
}
});
ndv.getters
.inputRunSelector()
.should('exist')
@ -243,38 +252,38 @@ describe('NDV', () => {
// biome-ignore format:
const PINNED_DATA = [
{
"id": "abc",
"historyId": "def",
"messages": [
id: 'abc',
historyId: 'def',
messages: [
{
"id": "abc"
}
]
id: 'abc',
},
],
},
{
"id": "abc",
"historyId": "def",
"messages": [
id: 'abc',
historyId: 'def',
messages: [
{
"id": "abc"
id: 'abc',
},
{
"id": "abc"
id: 'abc',
},
{
"id": "abc"
}
]
id: 'abc',
},
],
},
{
"id": "abc",
"historyId": "def",
"messages": [
id: 'abc',
historyId: 'def',
messages: [
{
"id": "abc"
}
]
}
id: 'abc',
},
],
},
];
workflowPage.actions.openNode('Get thread details1');
ndv.actions.pastePinnedData(PINNED_DATA);

View file

@ -3,24 +3,6 @@ import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
const workflowPage = new WorkflowPageClass();
function checkStickiesStyle(
top: number,
left: number,
height: number,
width: number,
zIndex?: number,
) {
workflowPage.getters.stickies().should(($el) => {
expect($el).to.have.css('top', `${top}px`);
expect($el).to.have.css('left', `${left}px`);
expect($el).to.have.css('height', `${height}px`);
expect($el).to.have.css('width', `${width}px`);
if (zIndex) {
expect($el).to.have.css('z-index', `${zIndex}`);
}
});
}
describe('Canvas Actions', () => {
beforeEach(() => {
workflowPage.actions.visit();
@ -51,191 +33,8 @@ describe('Canvas Actions', () => {
.contains('Guide')
.should('have.attr', 'href');
});
it('drags sticky around to top left corner', () => {
// used to caliberate move sticky function
addDefaultSticky();
moveSticky({ top: 0, left: 0 });
});
it('drags sticky around and position/size are saved correctly', () => {
addDefaultSticky();
moveSticky({ top: 500, left: 500 });
workflowPage.actions.saveWorkflowOnButtonClick();
cy.wait('@createWorkflow');
cy.reload();
cy.waitForLoad();
stickyShouldBePositionedCorrectly({ top: 500, left: 500 });
});
it('deletes sticky', () => {
workflowPage.actions.addSticky();
workflowPage.getters.stickies().should('have.length', 1);
workflowPage.actions.deleteSticky();
workflowPage.getters.stickies().should('have.length', 0);
});
it('edits sticky and updates content as markdown', () => {
workflowPage.actions.addSticky();
workflowPage.getters
.stickies()
.should('have.text', 'Im a note\nDouble click to edit me. Guide\n');
workflowPage.getters.stickies().dblclick();
workflowPage.actions.editSticky('# hello world \n ## text text');
workflowPage.getters.stickies().find('h1').should('have.text', 'hello world');
workflowPage.getters.stickies().find('h2').should('have.text', 'text text');
});
it('expands/shrinks sticky from the right edge', () => {
addDefaultSticky();
moveSticky({ top: 200, left: 200 });
cy.drag('[data-test-id="sticky"] [data-dir="right"]', [100, 100]);
checkStickiesStyle(100, 20, 160, 346);
cy.drag('[data-test-id="sticky"] [data-dir="right"]', [-50, -50]);
checkStickiesStyle(100, 20, 160, 302);
});
it('expands/shrinks sticky from the left edge', () => {
addDefaultSticky();
moveSticky({ left: 600, top: 200 });
cy.drag('[data-test-id="sticky"] [data-dir="left"]', [100, 100]);
checkStickiesStyle(100, 510, 160, 150);
cy.drag('[data-test-id="sticky"] [data-dir="left"]', [-50, -50]);
checkStickiesStyle(100, 466, 160, 194);
});
it('expands/shrinks sticky from the top edge', () => {
workflowPage.actions.addSticky();
cy.drag('[data-test-id="sticky"]', [100, 100]); // move away from canvas button
checkStickiesStyle(300, 620, 160, 240);
cy.drag('[data-test-id="sticky"] [data-dir="top"]', [100, 100]);
checkStickiesStyle(380, 620, 80, 240);
cy.drag('[data-test-id="sticky"] [data-dir="top"]', [-50, -50]);
checkStickiesStyle(324, 620, 136, 240);
});
it('expands/shrinks sticky from the bottom edge', () => {
workflowPage.actions.addSticky();
cy.drag('[data-test-id="sticky"]', [100, 100]); // move away from canvas button
checkStickiesStyle(300, 620, 160, 240);
cy.drag('[data-test-id="sticky"] [data-dir="bottom"]', [100, 100]);
checkStickiesStyle(300, 620, 254, 240);
cy.drag('[data-test-id="sticky"] [data-dir="bottom"]', [-50, -50]);
checkStickiesStyle(300, 620, 198, 240);
});
it('expands/shrinks sticky from the bottom right edge', () => {
workflowPage.actions.addSticky();
cy.drag('[data-test-id="sticky"]', [-100, -100]); // move away from canvas button
checkStickiesStyle(100, 420, 160, 240);
cy.drag('[data-test-id="sticky"] [data-dir="bottomRight"]', [100, 100]);
checkStickiesStyle(100, 420, 254, 346);
cy.drag('[data-test-id="sticky"] [data-dir="bottomRight"]', [-50, -50]);
checkStickiesStyle(100, 420, 198, 302);
});
it('expands/shrinks sticky from the top right edge', () => {
addDefaultSticky();
cy.drag('[data-test-id="sticky"] [data-dir="topRight"]', [100, 100]);
checkStickiesStyle(360, 400, 80, 346);
cy.drag('[data-test-id="sticky"] [data-dir="topRight"]', [-50, -50]);
checkStickiesStyle(304, 400, 136, 302);
});
it('expands/shrinks sticky from the top left edge, and reach min height/width', () => {
addDefaultSticky();
cy.drag('[data-test-id="sticky"] [data-dir="topLeft"]', [100, 100]);
checkStickiesStyle(360, 490, 80, 150);
cy.drag('[data-test-id="sticky"] [data-dir="topLeft"]', [-150, -150]);
checkStickiesStyle(204, 346, 236, 294);
});
it('sets sticky behind node', () => {
workflowPage.actions.addInitialNodeToCanvas('Manual Trigger');
addDefaultSticky();
cy.drag('[data-test-id="sticky"] [data-dir="topLeft"]', [-150, -150]);
checkStickiesStyle(124, 256, 316, 384, -121);
workflowPage.getters
.canvasNodes()
.eq(0)
.should(($el) => {
expect($el).to.have.css('z-index', 'auto');
});
workflowPage.actions.addSticky();
workflowPage.getters
.stickies()
.eq(0)
.should(($el) => {
expect($el).to.have.css('z-index', '-121');
});
workflowPage.getters
.stickies()
.eq(1)
.should(($el) => {
expect($el).to.have.css('z-index', '-38');
});
cy.drag('[data-test-id="sticky"] [data-dir="topLeft"]', [-200, -200], { index: 1 });
workflowPage.getters
.stickies()
.eq(0)
.should(($el) => {
expect($el).to.have.css('z-index', '-121');
});
workflowPage.getters
.stickies()
.eq(1)
.should(($el) => {
expect($el).to.have.css('z-index', '-158');
});
});
it('Empty sticky should not error when activating workflow', () => {
workflowPage.actions.addSticky();
workflowPage.getters.stickies().should('have.length', 1);
workflowPage.getters.stickies().dblclick();
workflowPage.actions.clearSticky();
workflowPage.actions.addNodeToCanvas('Schedule Trigger');
workflowPage.actions.activateWorkflow();
});
});
type Position = {
top: number;
left: number;
};
function shouldHaveOneSticky() {
workflowPage.getters.stickies().should('have.length', 1);
}
@ -263,17 +62,3 @@ function addDefaultSticky() {
shouldHaveDefaultSize();
shouldBeInDefaultLocation();
}
function stickyShouldBePositionedCorrectly(position: Position) {
const yOffset = -100;
const xOffset = -180;
workflowPage.getters.stickies().should(($el) => {
expect($el).to.have.css('top', `${yOffset + position.top}px`);
expect($el).to.have.css('left', `${xOffset + position.left}px`);
});
}
function moveSticky(target: Position) {
cy.drag('[data-test-id="sticky"]', [target.left, target.top], { abs: true });
stickyShouldBePositionedCorrectly(target);
}

View file

@ -1,86 +1,7 @@
import { getWorkflowHistoryCloseButton } from '../composables/workflow';
import {
CODE_NODE_NAME,
EDIT_FIELDS_SET_NODE_NAME,
IF_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
} from '../constants';
import { WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
import { WorkflowPage as WorkflowPageClass } from '../pages';
const workflowPage = new WorkflowPageClass();
const executionsTab = new WorkflowExecutionsTab();
const createNewWorkflowAndActivate = () => {
workflowPage.actions.visit();
workflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.actions.activateWorkflow();
cy.get('.el-notification .el-notification--error').should('not.exist');
};
const editWorkflowAndDeactivate = () => {
workflowPage.getters.canvasNodePlusEndpointByName(SCHEDULE_TRIGGER_NODE_NAME).click();
workflowPage.getters.nodeCreatorSearchBar().should('be.visible');
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, false);
cy.get('.jtk-connector').should('have.length', 1);
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.getters.activatorSwitch().click();
workflowPage.actions.zoomToFit();
cy.get('.el-notification .el-notification--error').should('not.exist');
};
const editWorkflowMoreAndActivate = () => {
cy.drag(workflowPage.getters.getEndpointSelector('plus', EDIT_FIELDS_SET_NODE_NAME), [200, 200], {
realMouse: true,
});
workflowPage.getters.nodeCreatorSearchBar().should('be.visible');
workflowPage.actions.addNodeToCanvas(CODE_NODE_NAME, false);
workflowPage.getters.nodeViewBackground().click(600, 200, { force: true });
cy.get('.jtk-connector').should('have.length', 2);
workflowPage.actions.zoomToFit();
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.actions.addNodeToCanvas(IF_NODE_NAME);
workflowPage.getters.nodeViewBackground().click(600, 200, { force: true });
cy.get('.jtk-connector').should('have.length', 2);
const position = {
top: 0,
left: 0,
};
workflowPage.getters
.canvasNodeByName(IF_NODE_NAME)
.click()
.then(($element) => {
position.top = $element.position().top;
position.left = $element.position().left;
});
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 200], { clickToFinish: true });
workflowPage.getters
.canvasNodes()
.last()
.then(($element) => {
const finalPosition = {
top: $element.position().top,
left: $element.position().left,
};
expect(finalPosition.top).to.be.greaterThan(position.top);
expect(finalPosition.left).to.be.greaterThan(position.left);
});
cy.draganddrop(
workflowPage.getters.getEndpointSelector('output', CODE_NODE_NAME),
workflowPage.getters.getEndpointSelector('input', IF_NODE_NAME),
);
cy.get('.jtk-connector').should('have.length', 3);
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.getters.activatorSwitch().click();
cy.get('.el-notification .el-notification--error').should('not.exist');
};
const switchBetweenEditorAndHistory = () => {
workflowPage.getters.workflowHistoryButton().click();
@ -116,62 +37,6 @@ const zoomInAndCheckNodes = () => {
workflowPage.getters.canvasNodes().last().should('not.be.visible');
};
describe('Editor actions should work', () => {
beforeEach(() => {
cy.enableFeature('debugInEditor');
cy.enableFeature('workflowHistory');
cy.signinAsOwner();
createNewWorkflowAndActivate();
});
it('after switching between Editor and Executions', () => {
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
executionsTab.actions.switchToExecutionsTab();
cy.wait(['@getExecutions']);
cy.wait(500);
executionsTab.actions.switchToEditorTab();
editWorkflowAndDeactivate();
editWorkflowMoreAndActivate();
});
it('after switching between Editor and Debug', () => {
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
cy.intercept('GET', '/rest/executions/*').as('getExecution');
cy.intercept('POST', '/rest/workflows/**/run?**').as('postWorkflowRun');
editWorkflowAndDeactivate();
workflowPage.actions.executeWorkflow();
cy.wait(['@postWorkflowRun']);
executionsTab.actions.switchToExecutionsTab();
cy.wait(['@getExecutions']);
executionsTab.getters.executionListItems().should('have.length', 1).first().click();
cy.wait(['@getExecution']);
executionsTab.getters.executionDebugButton().should('have.text', 'Copy to editor').click();
editWorkflowMoreAndActivate();
});
it('after switching between Editor and Workflow history', () => {
cy.intercept('GET', '/rest/workflow-history/workflow/*/version/*').as('getVersion');
cy.intercept('GET', '/rest/workflow-history/workflow/*').as('getHistory');
editWorkflowAndDeactivate();
workflowPage.getters.workflowHistoryButton().click();
cy.wait(['@getHistory']);
cy.wait(['@getVersion']);
cy.intercept('GET', '/rest/workflows/*').as('workflowGet');
getWorkflowHistoryCloseButton().click();
cy.wait(['@workflowGet']);
cy.wait(1000);
editWorkflowMoreAndActivate();
});
});
describe('Editor zoom should work after route changes', () => {
beforeEach(() => {
cy.enableFeature('debugInEditor');

View file

@ -28,7 +28,7 @@ import {
clickGetBackToCanvas,
getRunDataInfoCallout,
getOutputPanelTable,
toggleParameterCheckboxInputByName,
checkParameterCheckboxInputByName,
} from '../composables/ndv';
import {
addLanguageModelNodeToParent,
@ -38,8 +38,6 @@ import {
addToolNodeToParent,
clickExecuteWorkflowButton,
clickManualChatButton,
disableNode,
getExecuteWorkflowButton,
navigateToNewWorkflowPage,
getNodes,
openNode,
@ -73,31 +71,10 @@ describe('Langchain Integration', () => {
getManualChatModal().should('not.exist');
});
it('should disable test workflow button', () => {
addNodeToCanvas('Schedule Trigger', true);
addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true);
clickGetBackToCanvas();
addNodeToCanvas(AGENT_NODE_NAME, true, true);
clickGetBackToCanvas();
addLanguageModelNodeToParent(
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
AGENT_NODE_NAME,
true,
);
clickGetBackToCanvas();
disableNode('Schedule Trigger');
getExecuteWorkflowButton().should('be.disabled');
});
it('should add nodes to all Agent node input types', () => {
addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME, true);
addNodeToCanvas(AGENT_NODE_NAME, true, true);
toggleParameterCheckboxInputByName('hasOutputParser');
checkParameterCheckboxInputByName('hasOutputParser');
clickGetBackToCanvas();
addLanguageModelNodeToParent(
@ -368,58 +345,6 @@ describe('Langchain Integration', () => {
getConnectionBySourceAndTarget(CHAT_TRIGGER_NODE_DISPLAY_NAME, AGENT_NODE_NAME).should('exist');
getNodes().should('have.length', 3);
});
it('should render runItems for sub-nodes and allow switching between them', () => {
const workflowPage = new WorkflowPage();
const ndv = new NDV();
cy.visit(workflowPage.url);
cy.createFixtureWorkflow('In_memory_vector_store_fake_embeddings.json');
workflowPage.actions.zoomToFit();
workflowPage.actions.executeNode('Populate VS');
cy.get('[data-label="25 items"]').should('exist');
const assertInputOutputText = (text: string, assertion: 'exist' | 'not.exist') => {
ndv.getters.outputPanel().contains(text).should(assertion);
ndv.getters.inputPanel().contains(text).should(assertion);
};
workflowPage.actions.openNode('Character Text Splitter');
ndv.getters.outputRunSelector().should('exist');
ndv.getters.inputRunSelector().should('exist');
ndv.getters.inputRunSelector().find('input').should('include.value', '3 of 3');
ndv.getters.outputRunSelector().find('input').should('include.value', '3 of 3');
assertInputOutputText('Kyiv', 'exist');
assertInputOutputText('Berlin', 'not.exist');
assertInputOutputText('Prague', 'not.exist');
ndv.actions.changeOutputRunSelector('2 of 3');
assertInputOutputText('Berlin', 'exist');
assertInputOutputText('Kyiv', 'not.exist');
assertInputOutputText('Prague', 'not.exist');
ndv.actions.changeOutputRunSelector('1 of 3');
assertInputOutputText('Prague', 'exist');
assertInputOutputText('Berlin', 'not.exist');
assertInputOutputText('Kyiv', 'not.exist');
ndv.actions.toggleInputRunLinking();
ndv.actions.changeOutputRunSelector('2 of 3');
ndv.getters.inputRunSelector().find('input').should('include.value', '1 of 3');
ndv.getters.outputRunSelector().find('input').should('include.value', '2 of 3');
ndv.getters.inputPanel().contains('Prague').should('exist');
ndv.getters.inputPanel().contains('Berlin').should('not.exist');
ndv.getters.outputPanel().contains('Berlin').should('exist');
ndv.getters.outputPanel().contains('Prague').should('not.exist');
ndv.actions.toggleInputRunLinking();
ndv.getters.inputRunSelector().find('input').should('include.value', '1 of 3');
ndv.getters.outputRunSelector().find('input').should('include.value', '1 of 3');
assertInputOutputText('Prague', 'exist');
assertInputOutputText('Berlin', 'not.exist');
assertInputOutputText('Kyiv', 'not.exist');
});
it('should show tool info notice if no existing tools were used during execution', () => {
addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true);
@ -518,4 +443,29 @@ describe('Langchain Integration', () => {
getRunDataInfoCallout().should('not.exist');
});
it('should execute up to Node 1 when using partial execution', () => {
const workflowPage = new WorkflowPage();
const ndv = new NDV();
cy.visit(workflowPage.url);
cy.createFixtureWorkflow('Test_workflow_chat_partial_execution.json');
workflowPage.actions.zoomToFit();
getManualChatModal().should('not.exist');
openNode('Node 1');
ndv.actions.execute();
getManualChatModal().should('exist');
sendManualChatMessage('Test');
getManualChatMessages().should('contain', 'this_my_field_1');
cy.getByTestId('refresh-session-button').click();
cy.get('button').contains('Reset').click();
getManualChatMessages().should('not.exist');
sendManualChatMessage('Another test');
getManualChatMessages().should('contain', 'this_my_field_3');
getManualChatMessages().should('contain', 'this_my_field_4');
});
});

View file

@ -1,3 +1,5 @@
import { setCredentialValues } from '../composables/modals/credential-modal';
import { clickCreateNewCredential, selectResourceLocatorItem } from '../composables/ndv';
import * as projects from '../composables/projects';
import {
INSTANCE_ADMIN,
@ -11,18 +13,16 @@ import {
WorkflowPage,
CredentialsModal,
CredentialsPage,
WorkflowExecutionsTab,
NDV,
MainSidebar,
} from '../pages';
import { clearNotifications, successToast } from '../pages/notifications';
import { getVisibleDropdown, getVisibleModalOverlay, getVisibleSelect } from '../utils';
import { getVisibleSelect } from '../utils';
const workflowsPage = new WorkflowsPage();
const workflowPage = new WorkflowPage();
const credentialsPage = new CredentialsPage();
const credentialsModal = new CredentialsModal();
const executionsTab = new WorkflowExecutionsTab();
const ndv = new NDV();
const mainSidebar = new MainSidebar();
@ -36,207 +36,6 @@ describe('Projects', { disableAutoLogin: true }, () => {
cy.changeQuota('maxTeamProjects', -1);
});
it('should handle workflows and credentials and menu items', () => {
cy.signinAsAdmin();
cy.visit(workflowsPage.url);
workflowsPage.getters.workflowCards().should('not.have.length');
workflowsPage.getters.newWorkflowButtonCard().click();
cy.intercept('POST', '/rest/workflows').as('workflowSave');
workflowPage.actions.saveWorkflowOnButtonClick();
cy.wait('@workflowSave').then((interception) => {
expect(interception.request.body).not.to.have.property('projectId');
});
projects.getHomeButton().click();
projects.getProjectTabs().should('have.length', 3);
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('not.have.length');
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.getters.newCredentialModal().should('be.visible');
credentialsModal.getters.newCredentialTypeSelect().should('be.visible');
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
credentialsModal.getters.newCredentialTypeButton().click();
credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890');
credentialsModal.actions.setName('My awesome Notion account');
cy.intercept('POST', '/rest/credentials').as('credentialSave');
credentialsModal.actions.save();
cy.wait('@credentialSave').then((interception) => {
expect(interception.request.body).not.to.have.property('projectId');
});
credentialsModal.actions.close();
credentialsPage.getters.credentialCards().should('have.length', 1);
credentialsPage.getters
.credentialCards()
.first()
.find('.n8n-node-icon img')
.should('be.visible');
projects.getProjectTabWorkflows().click();
workflowsPage.getters.workflowCards().should('have.length', 1);
projects.getMenuItems().should('not.have.length');
cy.intercept('POST', '/rest/projects').as('projectCreate');
projects.getAddProjectButton().click();
cy.wait('@projectCreate');
projects.getMenuItems().should('have.length', 1);
projects.getProjectTabs().should('have.length', 3);
cy.get('input[name="name"]').type('Development');
projects.addProjectMember(INSTANCE_MEMBERS[0].email);
cy.intercept('PATCH', '/rest/projects/*').as('projectSettingsSave');
projects.getProjectSettingsSaveButton().click();
cy.wait('@projectSettingsSave').then((interception) => {
expect(interception.request.body).to.have.property('name').and.to.equal('Development');
expect(interception.request.body).to.have.property('relations').to.have.lengthOf(2);
});
projects.getMenuItems().first().click();
workflowsPage.getters.workflowCards().should('not.have.length');
projects.getProjectTabs().should('have.length', 4);
workflowsPage.getters.newWorkflowButtonCard().click();
cy.intercept('POST', '/rest/workflows').as('workflowSave');
workflowPage.actions.saveWorkflowOnButtonClick();
cy.wait('@workflowSave').then((interception) => {
expect(interception.request.body).to.have.property('projectId');
});
projects.getMenuItems().first().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('not.have.length');
credentialsPage.getters.emptyListCreateCredentialButton().click();
credentialsModal.getters.newCredentialModal().should('be.visible');
credentialsModal.getters.newCredentialTypeSelect().should('be.visible');
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
credentialsModal.getters.newCredentialTypeButton().click();
credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890');
credentialsModal.actions.setName('My awesome Notion account');
cy.intercept('POST', '/rest/credentials').as('credentialSave');
credentialsModal.actions.save();
cy.wait('@credentialSave').then((interception) => {
expect(interception.request.body).to.have.property('projectId');
});
credentialsModal.actions.close();
projects.getAddProjectButton().click();
projects.getMenuItems().should('have.length', 2);
let projectId: string;
projects.getMenuItems().first().click();
cy.intercept('GET', '/rest/credentials*').as('credentialsList');
projects.getProjectTabCredentials().click();
cy.wait('@credentialsList').then((interception) => {
const url = new URL(interception.request.url);
const queryParams = new URLSearchParams(url.search);
const filter = queryParams.get('filter');
expect(filter).to.be.a('string').and.to.contain('projectId');
if (filter) {
projectId = JSON.parse(filter).projectId;
}
});
projects.getMenuItems().last().click();
cy.intercept('GET', '/rest/credentials*').as('credentialsListProjectId');
projects.getProjectTabCredentials().click();
cy.wait('@credentialsListProjectId').then((interception) => {
const url = new URL(interception.request.url);
const queryParams = new URLSearchParams(url.search);
const filter = queryParams.get('filter');
expect(filter).to.be.a('string').and.to.contain('projectId');
if (filter) {
expect(JSON.parse(filter).projectId).not.to.equal(projectId);
}
});
projects.getHomeButton().click();
workflowsPage.getters.workflowCards().should('have.length', 2);
cy.intercept('GET', '/rest/credentials*').as('credentialsListUnfiltered');
projects.getProjectTabCredentials().click();
cy.wait('@credentialsListUnfiltered').then((interception) => {
expect(interception.request.url).not.to.contain('filter');
});
let menuItems = cy.getByTestId('menu-item');
menuItems.filter('[class*=active_]').should('have.length', 1);
menuItems.filter(':contains("Overview")[class*=active_]').should('exist');
projects.getMenuItems().first().click();
menuItems = cy.getByTestId('menu-item');
menuItems.filter('[class*=active_]').should('have.length', 1);
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
cy.intercept('GET', '/rest/workflows/*').as('loadWorkflow');
workflowsPage.getters.workflowCards().first().findChildByTestId('card-content').click();
cy.wait('@loadWorkflow');
menuItems = cy.getByTestId('menu-item');
menuItems.filter('[class*=active_]').should('have.length', 1);
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
cy.intercept('GET', '/rest/executions*').as('loadExecutions');
executionsTab.actions.switchToExecutionsTab();
cy.wait('@loadExecutions');
menuItems = cy.getByTestId('menu-item');
menuItems.filter('[class*=active_]').should('have.length', 1);
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
executionsTab.actions.switchToEditorTab();
menuItems = cy.getByTestId('menu-item');
menuItems.filter('[class*=active_]').should('have.length', 1);
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
cy.getByTestId('menu-item').filter(':contains("Variables")').click();
cy.getByTestId('unavailable-resources-list').should('be.visible');
menuItems = cy.getByTestId('menu-item');
menuItems.filter('[class*=active_]').should('have.length', 1);
menuItems.filter(':contains("Variables")[class*=active_]').should('exist');
projects.getHomeButton().click();
menuItems = cy.getByTestId('menu-item');
menuItems.filter('[class*=active_]').should('have.length', 1);
menuItems.filter(':contains("Overview")[class*=active_]').should('exist');
workflowsPage.getters.workflowCards().should('have.length', 2).first().click();
cy.wait('@loadWorkflow');
cy.getByTestId('execute-workflow-button').should('be.visible');
menuItems = cy.getByTestId('menu-item');
menuItems.filter(':contains("Overview")[class*=active_]').should('not.exist');
menuItems = cy.getByTestId('menu-item');
menuItems.filter('[class*=active_]').should('have.length', 1);
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
});
it('should not show project add button and projects to a member if not invited to any project', () => {
cy.signinAsMember(1);
cy.visit(workflowsPage.url);
@ -245,26 +44,6 @@ describe('Projects', { disableAutoLogin: true }, () => {
projects.getMenuItems().should('not.exist');
});
it('should not show viewer role if not licensed', () => {
cy.signinAsOwner();
cy.visit(workflowsPage.url);
projects.getMenuItems().first().click();
projects.getProjectTabSettings().click();
cy.get(
`[data-test-id="user-list-item-${INSTANCE_MEMBERS[0].email}"] [data-test-id="projects-settings-user-role-select"]`,
).click();
cy.get('.el-select-dropdown__item.is-disabled')
.should('contain.text', 'Viewer')
.get('span:contains("Upgrade")')
.filter(':visible')
.click();
getVisibleModalOverlay().should('contain.text', 'Upgrade to unlock additional roles');
});
describe('when starting from scratch', () => {
beforeEach(() => {
cy.resetDatabase();
@ -275,7 +54,11 @@ describe('Projects', { disableAutoLogin: true }, () => {
cy.changeQuota('maxTeamProjects', -1);
});
it('should filter credentials by project ID when creating new workflow or hard reloading an opened workflow', () => {
/**
* @TODO: New Canvas - Fix this test
*/
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
it.skip('should filter credentials by project ID when creating new workflow or hard reloading an opened workflow', () => {
cy.signinAsOwner();
cy.visit(workflowsPage.url);
@ -367,7 +150,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect()
.find('li')
.should('have.length', 2)
.should('have.length', 1)
.first()
.should('contain.text', 'Notion account project 1');
ndv.getters.backToCanvas().click();
@ -382,7 +165,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect()
.find('li')
.should('have.length', 2)
.should('have.length', 1)
.first()
.should('contain.text', 'Notion account project 1');
ndv.getters.backToCanvas().click();
@ -396,7 +179,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect()
.find('li')
.should('have.length', 2)
.should('have.length', 1)
.first()
.should('contain.text', 'Notion account project 2');
ndv.getters.backToCanvas().click();
@ -407,7 +190,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect()
.find('li')
.should('have.length', 2)
.should('have.length', 1)
.first()
.should('contain.text', 'Notion account project 2');
ndv.getters.backToCanvas().click();
@ -425,7 +208,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect()
.find('li')
.should('have.length', 2)
.should('have.length', 1)
.first()
.should('contain.text', 'Notion account personal project');
ndv.getters.backToCanvas().click();
@ -436,7 +219,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowPage.getters.nodeCredentialsSelect().first().click();
getVisibleSelect()
.find('li')
.should('have.length', 2)
.should('have.length', 1)
.first()
.should('contain.text', 'Notion account personal project');
});
@ -754,80 +537,64 @@ describe('Projects', { disableAutoLogin: true }, () => {
ndv.getters.credentialInput().find('input').should('be.enabled');
});
it('should handle viewer role', () => {
cy.enableFeature('projectRole:viewer');
it('should create sub-workflow and credential in the sub-workflow in the same project', () => {
cy.signinAsOwner();
cy.visit(workflowsPage.url);
projects.createProject('Development');
projects.addProjectMember(INSTANCE_MEMBERS[0].email, 'Viewer');
projects.getProjectSettingsSaveButton().click();
projects.createProject('Dev');
projects.getProjectTabWorkflows().click();
workflowsPage.getters.newWorkflowButtonCard().click();
projects.createWorkflow('Test_workflow_4_executions_view.json', 'WF with random error');
executionsTab.actions.createManualExecutions(2);
executionsTab.actions.toggleNodeEnabled('Error');
executionsTab.actions.createManualExecutions(2);
workflowPage.actions.saveWorkflowUsingKeyboardShortcut();
workflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
workflowPage.actions.saveWorkflowOnButtonClick();
workflowPage.actions.addNodeToCanvas('Execute Workflow', true, true);
projects.getMenuItems().first().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
projects.createCredential('Notion API');
cy.window().then((win) => {
cy.stub(win, 'open').callsFake((url) => {
cy.visit(url);
});
});
mainSidebar.actions.openUserMenu();
cy.getByTestId('user-menu-item-logout').click();
selectResourceLocatorItem('workflowId', 0, 'Create a');
cy.get('input[name="email"]').type(INSTANCE_MEMBERS[0].email);
cy.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password);
cy.getByTestId('form-submit-button').click();
cy.get('body').type('{esc}');
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
clickCreateNewCredential();
setCredentialValues({
apiKey: 'abc123',
});
ndv.actions.close();
workflowPage.actions.saveWorkflowOnButtonClick();
projects.getMenuItems().last().click();
projects.getProjectTabExecutions().click();
cy.getByTestId('global-execution-list-item').first().find('td:last button').click();
getVisibleDropdown()
.find('li')
.filter(':contains("Retry")')
.should('have.class', 'is-disabled');
getVisibleDropdown()
.find('li')
.filter(':contains("Delete")')
.should('have.class', 'is-disabled');
workflowsPage.getters.workflowCards().should('have.length', 2);
projects.getMenuItems().first().click();
cy.getByTestId('workflow-card-name').should('be.visible').first().click();
workflowPage.getters.nodeViewRoot().should('be.visible');
workflowPage.getters.executeWorkflowButton().should('not.exist');
workflowPage.getters.nodeCreatorPlusButton().should('not.exist');
workflowPage.getters.canvasNodes().should('have.length', 3).last().click();
cy.get('body').type('{backspace}');
workflowPage.getters.canvasNodes().should('have.length', 3).last().rightclick();
getVisibleDropdown()
.find('li')
.should('be.visible')
.filter(
':contains("Open"), :contains("Copy"), :contains("Select all"), :contains("Clear selection")',
)
.should('not.have.class', 'is-disabled');
cy.get('body').type('{esc}');
executionsTab.actions.switchToExecutionsTab();
cy.getByTestId('retry-execution-button')
.should('be.visible')
.find('.is-disabled')
.should('exist');
cy.get('button:contains("Debug")').should('be.disabled');
cy.get('button[title="Retry execution"]').should('be.disabled');
cy.get('button[title="Delete this execution"]').should('be.disabled');
projects.getMenuItems().first().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().filter(':contains("Notion")').click();
cy.getByTestId('node-credentials-config-container')
.should('be.visible')
.find('input')
.should('not.have.length');
credentialsPage.getters.credentialCards().should('have.length', 1);
});
it('should create credential from workflow in the correct project after editor page refresh', () => {
cy.signinAsOwner();
cy.visit(workflowsPage.url);
projects.createProject('Dev');
projects.getProjectTabWorkflows().click();
workflowsPage.getters.newWorkflowButtonCard().click();
workflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
workflowPage.actions.saveWorkflowOnButtonClick();
cy.reload();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
clickCreateNewCredential();
setCredentialValues({
apiKey: 'abc123',
});
ndv.actions.close();
workflowPage.actions.saveWorkflowOnButtonClick();
projects.getMenuItems().last().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('have.length', 1);
});
});

View file

@ -76,11 +76,21 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters.canvasAddButton().click();
WorkflowPage.actions.addNodeToCanvas('Manual', false);
nodeCreatorFeature.getters.canvasAddButton().should('not.be.visible');
nodeCreatorFeature.getters.nodeCreator().should('not.exist');
cy.ifCanvasVersion(
() => {
nodeCreatorFeature.getters.canvasAddButton().should('not.be.visible');
nodeCreatorFeature.getters.nodeCreator().should('not.exist');
// TODO: Replace once we have canvas feature utils
cy.get('div').contains('Add first step').should('be.hidden');
},
() => {
nodeCreatorFeature.getters.canvasAddButton().should('not.exist');
nodeCreatorFeature.getters.nodeCreator().should('not.exist');
// TODO: Replace once we have canvas feature utils
cy.get('div').contains('Add first step').should('not.exist');
},
);
// TODO: Replace once we have canvas feature utils
cy.get('div').contains('Add first step').should('be.hidden');
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.nodeCreator().contains('What happens next?').should('be.visible');
@ -125,7 +135,6 @@ describe('Node Creator', () => {
'OpenThesaurus',
'Spontit',
'Vonage',
'Send Email',
'Toggl Trigger',
];
const doubleActionNode = 'OpenWeatherMap';
@ -346,7 +355,15 @@ describe('Node Creator', () => {
it('should correctly append a No Op node when Loop Over Items node is added (from connection)', () => {
WorkflowPage.actions.addNodeToCanvas('Manual');
cy.get('.plus-endpoint').should('be.visible').click();
cy.ifCanvasVersion(
() => {
cy.get('.plus-endpoint').click();
},
() => {
cy.getByTestId('canvas-handle-plus').click();
},
);
nodeCreatorFeature.getters.searchBar().find('input').type('Loop Over Items');
nodeCreatorFeature.getters.getCreatorItem('Loop Over Items').click();
@ -531,7 +548,7 @@ describe('Node Creator', () => {
vectorStores.each((_i, vectorStore) => {
nodeCreatorFeature.getters.getCreatorItem(vectorStore).click();
actions.forEach((action) => {
nodeCreatorFeature.getters.getCreatorItem(action).should('be.visible');
nodeCreatorFeature.getters.getCreatorItem(action).should('be.visible').realHover();
});
cy.realPress('ArrowLeft');
});
@ -554,4 +571,13 @@ describe('Node Creator', () => {
addVectorStoreToolToParent('In-Memory Vector Store', AGENT_NODE_NAME);
});
it('should insert node to canvas with sendAndWait operation selected', () => {
nodeCreatorFeature.getters.canvasAddButton().click();
WorkflowPage.actions.addNodeToCanvas('Manual', false);
nodeCreatorFeature.actions.openNodeCreator();
cy.contains('Human in the loop').click();
nodeCreatorFeature.getters.getCreatorItem('Slack').click();
cy.contains('Send and Wait for Response').should('exist');
});
});

View file

@ -5,7 +5,6 @@ import { GMAIL_NODE_NAME, SCHEDULE_TRIGGER_NODE_NAME } from '../constants';
import { CredentialsModal, CredentialsPage, NDV, WorkflowPage } from '../pages';
import { AIAssistant } from '../pages/features/ai-assistant';
import { NodeCreator } from '../pages/features/node-creator';
import { getVisibleSelect } from '../utils';
const wf = new WorkflowPage();
const ndv = new NDV();
@ -434,7 +433,7 @@ describe('AI Assistant Credential Help', () => {
wf.actions.addNodeToCanvas('Slack', true, true, 'Get a channel');
wf.getters.nodeCredentialsSelect().should('exist');
wf.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
wf.getters.nodeCredentialsCreateOption().click();
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
ndv.getters.copyInput().should('not.exist');
credentialsModal.getters.oauthConnectButton().should('have.length', 1);
@ -467,7 +466,7 @@ describe('AI Assistant Credential Help', () => {
wf.actions.addNodeToCanvas('Microsoft Outlook', true, true, 'Get a calendar');
wf.getters.nodeCredentialsSelect().should('exist');
wf.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
wf.getters.nodeCredentialsCreateOption().click();
ndv.getters.copyInput().should('not.exist');
credentialsModal.getters.oauthConnectButton().should('have.length', 1);
credentialsModal.getters.credentialInputs().should('have.length', 1);

View file

@ -17,7 +17,7 @@ describe('Workflow Selector Parameter', () => {
workflowPage.actions.visit();
workflowPage.actions.addInitialNodeToCanvas(EXECUTE_WORKFLOW_NODE_NAME, {
keepNdvOpen: true,
action: 'Call Another Workflow',
action: 'Execute A Sub Workflow',
});
});
it('should render sub-workflows list', () => {
@ -86,6 +86,8 @@ describe('Workflow Selector Parameter', () => {
cy.stub(win, 'open').as('windowOpen');
});
cy.intercept('POST', '/rest/workflows*').as('createSubworkflow');
ndv.getters.resourceLocator('workflowId').should('be.visible');
ndv.getters.resourceLocatorInput('workflowId').click();
@ -94,14 +96,24 @@ describe('Workflow Selector Parameter', () => {
.findChildByTestId('rlc-item')
.eq(0)
.find('span')
.should('have.text', 'Create a new sub-workflow');
.should('contain.text', 'Create a'); // Due to some inconsistency we're sometimes in a project and sometimes not, this covers both cases
getVisiblePopper().findChildByTestId('rlc-item').eq(0).click();
const SAMPLE_SUBWORKFLOW_TEMPLATE_ID = 'VMiAxXa3lCAizGB5f7dVZQSFfg3FtHkdTKvLuupqBls=';
cy.get('@windowOpen').should(
'be.calledWith',
`/workflows/onboarding/${SAMPLE_SUBWORKFLOW_TEMPLATE_ID}?sampleSubWorkflows=0`,
);
cy.wait('@createSubworkflow').then((interception) => {
expect(interception.request.body).to.have.property('name').that.includes('Sub-Workflow');
expect(interception.request.body.nodes).to.be.an('array');
expect(interception.request.body.nodes).to.have.length(2);
expect(interception.request.body.nodes[0]).to.have.property(
'name',
'When Executed by Another Workflow',
);
expect(interception.request.body.nodes[1]).to.have.property(
'name',
'Replace me with your logic',
);
});
cy.get('@windowOpen').should('be.calledWithMatch', /\/workflow\/.+/);
});
});

View file

@ -1,9 +1,3 @@
import {
getExecutionPreviewOutputPanelRelatedExecutionLink,
getExecutionsSidebar,
getWorkflowExecutionPreviewIframe,
openExecutionPreviewNode,
} from '../composables/executions';
import {
changeOutputRunSelector,
getOutputPanelItemsCount,
@ -103,38 +97,4 @@ describe('Subworkflow debugging', () => {
getOutputTbodyCell(1, 2).should('include.text', 'Terry.Dach@hotmail.com');
});
});
it('can inspect parent executions', () => {
cy.url().then((workflowUrl) => {
openNode('Execute Workflow with param');
getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution');
getOutputPanelRelatedExecutionLink().should('contain.text', 'View sub-execution');
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
// ensure workflow executed and waited on output
getOutputTableHeaders().should('have.length', 2);
getOutputTbodyCell(1, 0).should('have.text', 'world Natalie Moore');
// cypress cannot handle new tabs so removing it
getOutputPanelRelatedExecutionLink().invoke('removeAttr', 'target').click();
getExecutionsSidebar().should('be.visible');
getWorkflowExecutionPreviewIframe().should('be.visible');
openExecutionPreviewNode('Execute Workflow Trigger');
getExecutionPreviewOutputPanelRelatedExecutionLink().should(
'include.text',
'View parent execution',
);
getExecutionPreviewOutputPanelRelatedExecutionLink()
.invoke('removeAttr', 'target')
.click({ force: true });
cy.url().then((currentUrl) => {
expect(currentUrl === workflowUrl);
});
});
});
});

View file

@ -1,60 +1,227 @@
import { clickGetBackToCanvas, getOutputTableHeaders } from '../composables/ndv';
import {
addItemToFixedCollection,
assertNodeOutputHintExists,
clickExecuteNode,
clickGetBackToCanvas,
getExecuteNodeButton,
getOutputTableHeaders,
getParameterInputByName,
populateFixedCollection,
selectResourceLocatorItem,
typeIntoFixedCollectionItem,
clickWorkflowCardContent,
assertOutputTableContent,
populateMapperFields,
getNodeRunInfoStale,
assertNodeOutputErrorMessageExists,
checkParameterCheckboxInputByName,
uncheckParameterCheckboxInputByName,
} from '../composables/ndv';
import {
clickExecuteWorkflowButton,
clickZoomToFit,
navigateToNewWorkflowPage,
openNode,
pasteWorkflow,
saveWorkflowOnButtonClick,
} from '../composables/workflow';
import { visitWorkflowsPage } from '../composables/workflowsPage';
import SUB_WORKFLOW_INPUTS from '../fixtures/Test_Subworkflow-Inputs.json';
import { NDV, WorkflowsPage, WorkflowPage } from '../pages';
import { errorToast, successToast } from '../pages/notifications';
import { getVisiblePopper } from '../utils';
const ndv = new NDV();
const workflowsPage = new WorkflowsPage();
const workflow = new WorkflowPage();
const DEFAULT_WORKFLOW_NAME = 'My workflow';
const DEFAULT_SUBWORKFLOW_NAME_1 = 'My Sub-Workflow 1';
const DEFAULT_SUBWORKFLOW_NAME_2 = 'My Sub-Workflow 2';
type FieldRow = readonly string[];
const exampleFields = [
const EXAMPLE_FIELDS = [
['aNumber', 'Number'],
['aString', 'String'],
['aArray', 'Array'],
['aObject', 'Object'],
['aAny', 'Allow Any Type'],
// bool last since it's not an inputField so we'll skip it for some cases
// bool last because it's a switch instead of a normal inputField so we'll skip it for some cases
['aBool', 'Boolean'],
] as const;
/**
* Populate multiValue fixedCollections. Only supports fixedCollections for which all fields can be defined via keyboard typing
*
* @param items - 2D array of items to populate, i.e. [["myField1", "String"], [""]
* @param collectionName - name of the fixedCollection to populate
* @param offset - amount of 'parameter-input's before the fixedCollection under test
* @returns
*/
function populateFixedCollection(
items: readonly FieldRow[],
collectionName: string,
offset: number,
) {
if (items.length === 0) return;
const n = items[0].length;
for (const [i, params] of items.entries()) {
ndv.actions.addItemToFixedCollection(collectionName);
for (const [j, param] of params.entries()) {
ndv.getters
.fixedCollectionParameter(collectionName)
.getByTestId('parameter-input')
.eq(offset + i * n + j)
.type(`${param}{downArrow}{enter}`);
}
type TypeField = 'Allow Any Type' | 'String' | 'Number' | 'Boolean' | 'Array' | 'Object';
describe('Sub-workflow creation and typed usage', () => {
beforeEach(() => {
navigateToNewWorkflowPage();
pasteWorkflow(SUB_WORKFLOW_INPUTS);
saveWorkflowOnButtonClick();
clickZoomToFit();
openNode('Execute Workflow');
// Prevent sub-workflow from opening in new window
cy.window().then((win) => {
cy.stub(win, 'open').callsFake((url) => {
cy.visit(url);
});
});
selectResourceLocatorItem('workflowId', 0, 'Create a');
// **************************
// NAVIGATE TO CHILD WORKFLOW
// **************************
// Close NDV before opening the node creator
cy.get('body').type('{esc}');
openNode('When Executed by Another Workflow');
});
it('works with type-checked values', () => {
populateFixedCollection(EXAMPLE_FIELDS, 'workflowInputs', 1);
validateAndReturnToParent(
DEFAULT_SUBWORKFLOW_NAME_1,
1,
EXAMPLE_FIELDS.map((f) => f[0]),
);
const values = [
'-1', // number fields don't support `=` switch to expression, so let's test the Fixed case with it
...EXAMPLE_FIELDS.slice(1).map((x) => `={{}{{} $json.a${x[0]}`), // the `}}` at the end are added automatically
];
// this matches with the pinned data provided in the fixture
populateMapperFields(values.map((x, i) => [EXAMPLE_FIELDS[i][0], x]));
clickExecuteNode();
const expected = [
['-1', 'A String', '0:11:true2:3', 'aKey:-1', '[empty object]', 'true'],
['-1', 'Another String', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'true'],
];
assertOutputTableContent(expected);
// Test the type-checking options
populateMapperFields([['aString', '{selectAll}{backspace}{{}{{} 5']]);
getNodeRunInfoStale().should('exist');
clickExecuteNode();
assertNodeOutputErrorMessageExists();
// attemptToConvertTypes enabled
checkParameterCheckboxInputByName('attemptToConvertTypes');
getNodeRunInfoStale().should('exist');
clickExecuteNode();
const expected2 = [
['-1', '5', '0:11:true2:3', 'aKey:-1', '[empty object]', 'true'],
['-1', '5', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'true'],
];
assertOutputTableContent(expected2);
// disabled again
uncheckParameterCheckboxInputByName('attemptToConvertTypes');
getNodeRunInfoStale().should('exist');
clickExecuteNode();
assertNodeOutputErrorMessageExists();
});
it('works with Fields input source, and can then be changed to JSON input source', () => {
assertNodeOutputHintExists();
populateFixedCollection(EXAMPLE_FIELDS, 'workflowInputs', 1);
validateAndReturnToParent(
DEFAULT_SUBWORKFLOW_NAME_1,
1,
EXAMPLE_FIELDS.map((f) => f[0]),
);
cy.window().then((win) => {
cy.stub(win, 'open').callsFake((url) => {
cy.visit(url);
selectResourceLocatorItem('workflowId', 0, 'Create a');
openNode('When Executed by Another Workflow');
getParameterInputByName('inputSource').click();
getVisiblePopper()
.getByTestId('parameter-input')
.eq(0)
.type('Using JSON Example{downArrow}{enter}');
const exampleJson =
'{{}' + EXAMPLE_FIELDS.map((x) => `"${x[0]}": ${makeExample(x[1])}`).join(',') + '}';
getParameterInputByName('jsonExample')
.find('.cm-line')
.eq(0)
.type(`{selectAll}{backspace}${exampleJson}{enter}`);
// first one doesn't work for some reason, might need to wait for something?
clickExecuteNode();
validateAndReturnToParent(
DEFAULT_SUBWORKFLOW_NAME_2,
2,
EXAMPLE_FIELDS.map((f) => f[0]),
);
assertOutputTableContent([
['[null]', '[null]', '[null]', '[null]', '[null]', 'true'],
['[null]', '[null]', '[null]', '[null]', '[null]', 'true'],
]);
clickExecuteNode();
});
});
});
it('should show node issue when no fields are defined in manual mode', () => {
getExecuteNodeButton().should('be.disabled');
clickGetBackToCanvas();
// Executing the workflow should show an error toast
clickExecuteWorkflowButton();
errorToast().should('contain', 'The workflow has issues');
openNode('When Executed by Another Workflow');
// Add a field to the workflowInputs fixedCollection
addItemToFixedCollection('workflowInputs');
typeIntoFixedCollectionItem('workflowInputs', 0, 'test');
// Executing the workflow should not show error now
clickGetBackToCanvas();
clickExecuteWorkflowButton();
successToast().should('contain', 'Workflow executed successfully');
});
});
// This function starts off in the Child Workflow Input Trigger, assuming we just defined the input fields
// It then navigates back to the parent and validates the outputPanel matches our changes
function validateAndReturnToParent(targetChild: string, offset: number, fields: string[]) {
clickExecuteNode();
// + 1 to account for formatting-only column
getOutputTableHeaders().should('have.length', fields.length + 1);
for (const [i, name] of fields.entries()) {
getOutputTableHeaders().eq(i).should('have.text', name);
}
clickGetBackToCanvas();
saveWorkflowOnButtonClick();
visitWorkflowsPage();
clickWorkflowCardContent(DEFAULT_WORKFLOW_NAME);
openNode('Execute Workflow');
// Note that outside of e2e tests this will be pre-selected correctly.
// Due to our workaround to remain in the same tab we need to select the correct tab manually
selectResourceLocatorItem('workflowId', offset, targetChild);
clickExecuteNode();
getOutputTableHeaders().should('have.length', fields.length + 1);
for (const [i, name] of fields.entries()) {
getOutputTableHeaders().eq(i).should('have.text', name);
}
}
@ -74,215 +241,3 @@ function makeExample(type: TypeField) {
return 'null';
}
}
type TypeField = 'Allow Any Type' | 'String' | 'Number' | 'Boolean' | 'Array' | 'Object';
function populateFields(items: ReadonlyArray<readonly [string, TypeField]>) {
populateFixedCollection(items, 'workflowInputs', 1);
}
function navigateWorkflowSelectionDropdown(index: number, expectedText: string) {
ndv.getters.resourceLocator('workflowId').should('be.visible');
ndv.getters.resourceLocatorInput('workflowId').click();
getVisiblePopper().findChildByTestId('rlc-item').eq(0).should('exist');
getVisiblePopper()
.findChildByTestId('rlc-item')
.eq(index)
.find('span')
.should('have.text', expectedText)
.click();
}
function populateMapperFields(values: readonly string[], offset: number) {
for (const [i, value] of values.entries()) {
cy.getByTestId('parameter-input')
.eq(offset + i)
.type(value);
// Click on a parent to dismiss the pop up hiding the field below.
cy.getByTestId('parameter-input')
.eq(offset + i)
.parent()
.parent()
.click('topLeft');
}
}
// This function starts off in the Child Workflow Input Trigger, assuming we just defined the input fields
// It then navigates back to the parent and validates output
function validateAndReturnToParent(targetChild: string, offset: number, fields: string[]) {
ndv.actions.execute();
// + 1 to account for formatting-only column
getOutputTableHeaders().should('have.length', fields.length + 1);
for (const [i, name] of fields.entries()) {
getOutputTableHeaders().eq(i).should('have.text', name);
}
clickGetBackToCanvas();
saveWorkflowOnButtonClick();
cy.visit(workflowsPage.url);
workflowsPage.getters.workflowCardContent(DEFAULT_WORKFLOW_NAME).click();
openNode('Execute Workflow');
// Note that outside of e2e tests this will be pre-selected correctly.
// Due to our workaround to remain in the same tab we need to select the correct tab manually
navigateWorkflowSelectionDropdown(offset, targetChild);
// This fails, pointing to `usePushConnection` `const triggerNode = subWorkflow?.nodes.find` being `undefined.find()`I <think>
ndv.actions.execute();
getOutputTableHeaders().should('have.length', fields.length + 1);
for (const [i, name] of fields.entries()) {
getOutputTableHeaders().eq(i).should('have.text', name);
}
// todo: verify the fields appear and show the correct types
// todo: fill in the input fields (and mock previous node data in the json fixture to match)
// todo: validate the actual output data
}
function setWorkflowInputFieldValue(index: number, value: string) {
ndv.actions.addItemToFixedCollection('workflowInputs');
ndv.actions.typeIntoFixedCollectionItem('workflowInputs', index, value);
}
describe('Sub-workflow creation and typed usage', () => {
beforeEach(() => {
navigateToNewWorkflowPage();
pasteWorkflow(SUB_WORKFLOW_INPUTS);
saveWorkflowOnButtonClick();
clickZoomToFit();
openNode('Execute Workflow');
// Prevent sub-workflow from opening in new window
cy.window().then((win) => {
cy.stub(win, 'open').callsFake((url) => {
cy.visit(url);
});
});
navigateWorkflowSelectionDropdown(0, 'Create a new sub-workflow');
// **************************
// NAVIGATE TO CHILD WORKFLOW
// **************************
openNode('Workflow Input Trigger');
});
it('works with type-checked values', () => {
populateFields(exampleFields);
validateAndReturnToParent(
DEFAULT_SUBWORKFLOW_NAME_1,
1,
exampleFields.map((f) => f[0]),
);
const values = [
'-1', // number fields don't support `=` switch to expression, so let's test the Fixed case with it
...exampleFields.slice(1).map((x) => `={{}{{} $json.a${x[0]}`), // }} are added automatically
];
// this matches with the pinned data provided in the fixture
populateMapperFields(values, 2);
ndv.actions.execute();
// todo:
// - validate output lines up
// - change input to need casts
// - run
// - confirm error
// - switch `attemptToConvertTypes` flag
// - confirm success and changed output
// - change input to be invalid despite cast
// - run
// - confirm error
// - switch type option flags
// - run
// - confirm success
// - turn off attempt to cast flag
// - confirm a value was not cast
});
it('works with Fields input source into JSON input source', () => {
ndv.getters.nodeOutputHint().should('exist');
populateFields(exampleFields);
validateAndReturnToParent(
DEFAULT_SUBWORKFLOW_NAME_1,
1,
exampleFields.map((f) => f[0]),
);
cy.window().then((win) => {
cy.stub(win, 'open').callsFake((url) => {
cy.visit(url);
});
});
navigateWorkflowSelectionDropdown(0, 'Create a new sub-workflow');
openNode('Workflow Input Trigger');
cy.getByTestId('parameter-input').eq(0).click();
// Todo: Check if there's a better way to interact with option dropdowns
// This PR would add this child testId
getVisiblePopper()
.getByTestId('parameter-input')
.eq(0)
.type('Using JSON Example{downArrow}{enter}');
const exampleJson =
'{{}' + exampleFields.map((x) => `"${x[0]}": ${makeExample(x[1])}`).join(',') + '}';
cy.getByTestId('parameter-input-jsonExample')
.find('.cm-line')
.eq(0)
.type(`{selectAll}{backspace}${exampleJson}{enter}`);
// first one doesn't work for some reason, might need to wait for something?
ndv.actions.execute();
validateAndReturnToParent(
DEFAULT_SUBWORKFLOW_NAME_2,
2,
exampleFields.map((f) => f[0]),
);
// test for either InputSource mode and options combinations:
// + we're showing the notice in the output panel
// + we start with no fields
// + Test Step works and we create the fields
// + create field of each type (string, number, boolean, object, array, any)
// + exit ndv
// + save
// + go back to parent workflow
// - verify fields appear [needs Ivan's PR]
// - link fields [needs Ivan's PR]
// + run parent
// - verify output with `null` defaults exists
//
});
it('should show node issue when no fields are defined in manual mode', () => {
ndv.getters.nodeExecuteButton().should('be.disabled');
ndv.actions.close();
// Executing the workflow should show an error toast
workflow.actions.executeWorkflow();
errorToast().should('contain', 'The workflow has issues');
openNode('Workflow Input Trigger');
// Add a field to the workflowInputs fixedCollection
setWorkflowInputFieldValue(0, 'test');
// Executing the workflow should not show error now
ndv.actions.close();
workflow.actions.executeWorkflow();
successToast().should('contain', 'Workflow executed successfully');
});
});

View file

@ -1,6 +1,10 @@
import { setCredentialValues } from '../composables/modals/credential-modal';
import { clickCreateNewCredential } from '../composables/ndv';
import { MANUAL_TRIGGER_NODE_DISPLAY_NAME, NOTION_NODE_NAME } from '../constants';
import { clickCreateNewCredential, setParameterSelectByContent } from '../composables/ndv';
import {
EDIT_FIELDS_SET_NODE_NAME,
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
NOTION_NODE_NAME,
} from '../constants';
import { NDV, WorkflowPage } from '../pages';
import { NodeCreator } from '../pages/features/node-creator';
@ -106,7 +110,10 @@ describe('NDV', () => {
ndv.actions.execute();
ndv.getters
.nodeRunErrorMessage()
.should('have.text', 'Info for expression missing from previous node');
.should(
'have.text',
"Using the item method doesn't work with pinned data in this scenario. Please unpin 'Break pairedItem chain' and try again.",
);
ndv.getters
.nodeRunErrorDescription()
.should(
@ -242,6 +249,15 @@ describe('NDV', () => {
ndv.actions.switchInputMode('Table');
ndv.actions.switchOutputMode('Table');
// Start from linked state
ndv.getters.outputLinkRun().then(($el) => {
const classList = Array.from($el[0].classList);
if (!classList.includes('linked')) {
ndv.actions.toggleOutputRunLinking();
ndv.getters.inputTbodyCell(1, 0).click(); // remove tooltip
}
});
ndv.getters
.inputRunSelector()
.should('exist')
@ -359,15 +375,71 @@ describe('NDV', () => {
ndv.getters.nodeExecuteButton().should('be.visible');
});
it('should allow editing code in fullscreen in the Code node', () => {
it('should allow editing code in fullscreen in the code editors', () => {
// Code (JavaScript)
workflowPage.actions.addInitialNodeToCanvas('Code', { keepNdvOpen: true });
ndv.actions.openCodeEditorFullscreen();
ndv.getters.codeEditorFullscreen().type('{selectall}').type('{backspace}').type('foo()');
ndv.getters.codeEditorFullscreen().should('contain.text', 'foo()');
cy.wait(200);
cy.wait(200); // allow change to emit before closing modal
ndv.getters.codeEditorDialog().find('.el-dialog__close').click();
ndv.getters.parameterInput('jsCode').get('.cm-content').should('contain.text', 'foo()');
ndv.actions.close();
// SQL
workflowPage.actions.addNodeToCanvas('Postgres', true, true, 'Execute a SQL query');
ndv.actions.openCodeEditorFullscreen();
ndv.getters
.codeEditorFullscreen()
.type('{selectall}')
.type('{backspace}')
.type('SELECT * FROM workflows');
ndv.getters.codeEditorFullscreen().should('contain.text', 'SELECT * FROM workflows');
cy.wait(200);
ndv.getters.codeEditorDialog().find('.el-dialog__close').click();
ndv.getters
.parameterInput('query')
.get('.cm-content')
.should('contain.text', 'SELECT * FROM workflows');
ndv.actions.close();
// HTML
workflowPage.actions.addNodeToCanvas('HTML', true, true, 'Generate HTML template');
ndv.actions.openCodeEditorFullscreen();
ndv.getters
.codeEditorFullscreen()
.type('{selectall}')
.type('{backspace}')
.type('<div>Hello World');
ndv.getters.codeEditorFullscreen().should('contain.text', '<div>Hello World</div>');
cy.wait(200);
ndv.getters.codeEditorDialog().find('.el-dialog__close').click();
ndv.getters
.parameterInput('html')
.get('.cm-content')
.should('contain.text', '<div>Hello World</div>');
ndv.actions.close();
// JSON
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true);
setParameterSelectByContent('mode', 'JSON');
ndv.actions.openCodeEditorFullscreen();
ndv.getters
.codeEditorFullscreen()
.type('{selectall}')
.type('{backspace}')
.type('{ "key": "value" }', { parseSpecialCharSequences: false });
ndv.getters.codeEditorFullscreen().should('contain.text', '{ "key": "value" }');
cy.wait(200);
ndv.getters.codeEditorDialog().find('.el-dialog__close').click();
ndv.getters
.parameterInput('jsonOutput')
.get('.cm-content')
.should('contain.text', '{ "key": "value" }');
});
it('should not retrieve remote options when a parameter value changes', () => {

View file

@ -7,6 +7,9 @@ import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
const WorkflowPage = new WorkflowPageClass();
const ndv = new NDV();
const getParameter = () => ndv.getters.parameterInput('jsCode').should('be.visible');
const getEditor = () => getParameter().find('.cm-content').should('exist');
describe('Code node', () => {
describe('Code editor', () => {
beforeEach(() => {
@ -40,10 +43,23 @@ describe('Code node', () => {
successToast().contains('Node executed successfully');
});
it('should show lint errors in `runOnceForAllItems` mode', () => {
const getParameter = () => ndv.getters.parameterInput('jsCode').should('be.visible');
const getEditor = () => getParameter().find('.cm-content').should('exist');
it('should allow switching between sibling code nodes', () => {
// Setup
getEditor().type('{selectall}').paste("console.log('code node 1')");
ndv.actions.close();
WorkflowPage.actions.addNodeToCanvas('Code', true, true);
getEditor().type('{selectall}').paste("console.log('code node 2')");
ndv.actions.close();
WorkflowPage.actions.openNode('Code');
ndv.actions.clickFloatingNode('Code1');
getEditor().should('have.text', "console.log('code node 2')");
ndv.actions.clickFloatingNode('Code');
getEditor().should('have.text', "console.log('code node 1')");
});
it('should show lint errors in `runOnceForAllItems` mode', () => {
getEditor()
.type('{selectall}')
.paste(`$input.itemMatching()
@ -66,9 +82,6 @@ return
});
it('should show lint errors in `runOnceForEachItem` mode', () => {
const getParameter = () => ndv.getters.parameterInput('jsCode').should('be.visible');
const getEditor = () => getParameter().find('.cm-content').should('exist');
ndv.getters.parameterInput('mode').click();
ndv.actions.selectOptionInParameterDropdown('mode', 'Run Once for Each Item');
getEditor()

View file

@ -200,7 +200,14 @@ describe('Workflow Actions', () => {
WorkflowPage.getters.nodeConnections().should('have.length', 2);
// Check if all nodes have names
WorkflowPage.getters.canvasNodes().each((node) => {
cy.wrap(node).should('have.attr', 'data-name');
cy.ifCanvasVersion(
() => {
cy.wrap(node).should('have.attr', 'data-name');
},
() => {
cy.wrap(node).should('have.attr', 'data-node-name');
},
);
});
});
});

View file

@ -105,7 +105,7 @@ describe('Expression editor modal', () => {
// Run workflow
cy.get('body').type('{esc}');
ndv.actions.close();
WorkflowPage.actions.executeNode('No Operation');
WorkflowPage.actions.executeNode('No Operation, do nothing', { anchor: 'topLeft' });
WorkflowPage.actions.openNode('Hacker News');
WorkflowPage.actions.openExpressionEditorModal();

View file

@ -0,0 +1,77 @@
{
"nodes": [
{
"parameters": {
"options": {}
},
"id": "535fd3dd-e78f-4ffa-a085-79723fc81b38",
"name": "When chat message received",
"type": "@n8n/n8n-nodes-langchain.chatTrigger",
"typeVersion": 1.1,
"position": [
320,
-380
],
"webhookId": "4fb58136-3481-494a-a30f-d9e064dac186"
},
{
"parameters": {
"mode": "raw",
"jsonOutput": "{\n \"this_my_field_1\": \"value\",\n \"this_my_field_2\": 1\n}\n",
"options": {}
},
"id": "78201ec2-6def-40b7-85e5-97b580d7f642",
"name": "Node 1",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
580,
-380
]
},
{
"parameters": {
"mode": "raw",
"jsonOutput": "{\n \"this_my_field_3\": \"value\",\n \"this_my_field_4\": 1\n}\n",
"options": {}
},
"id": "1cfca06d-3ec3-427f-89f7-1ef321e025ff",
"name": "Node 2",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
780,
-380
]
}
],
"connections": {
"When chat message received": {
"main": [
[
{
"node": "Node 1",
"type": "main",
"index": 0
}
]
]
},
"Node 1": {
"main": [
[
{
"node": "Node 2",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "178ef8a5109fc76c716d40bcadb720c455319f7b7a3fd5a39e4f336a091f524a"
}
}

View file

@ -0,0 +1,76 @@
{
"nodes": [
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "6a8c3d85-26f8-4f28-ace9-55a196a23d37",
"name": "prevNode",
"value": "={{ $prevNode.name }}",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [200, -100],
"id": "351ce967-0399-4a78-848a-9cc69b831796",
"name": "Edit Fields"
},
{
"parameters": {
"rule": {
"interval": [{}]
}
},
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [0, -100],
"id": "cf2f58a8-1fbb-4c70-b2b1-9e06bee7ec47",
"name": "Trigger A"
},
{
"parameters": {
"rule": {
"interval": [{}]
}
},
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [0, 100],
"id": "4fade34e-2bfc-4a2e-a8ed-03ab2ed9c690",
"name": "Trigger B"
}
],
"connections": {
"Trigger A": {
"main": [
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
},
"Trigger B": {
"main": [
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"meta": {
"instanceId": "0dd4627b77a5a795ab9bf073e5812be94dd8d1a5f012248ef2a4acac09be12cb"
}
}

View file

@ -6,7 +6,7 @@
"cypress:install": "cypress install",
"test:e2e:ui": "scripts/run-e2e.js ui",
"test:e2e:dev": "scripts/run-e2e.js dev",
"test:e2e:dev:v2": "scripts/run-e2e.js dev:v2",
"test:e2e:dev:v1": "scripts/run-e2e.js dev:v1",
"test:e2e:all": "scripts/run-e2e.js all",
"format": "biome format --write .",
"format:check": "biome ci .",

View file

@ -151,6 +151,9 @@ export class NDV extends BasePage {
schemaViewNodeName: () => cy.getByTestId('run-data-schema-node-name'),
expressionExpanders: () => cy.getByTestId('expander'),
expressionModalOutput: () => cy.getByTestId('expression-modal-output'),
floatingNodes: () => cy.getByTestId('floating-node'),
floatingNodeByName: (name: string) =>
cy.getByTestId('floating-node').filter(`[data-node-name="${name}"]`),
};
actions = {
@ -339,6 +342,9 @@ export class NDV extends BasePage {
dragMainPanelToRight: () => {
cy.drag('[data-test-id=panel-drag-button]', [1000, 0], { moveTwice: true });
},
clickFloatingNode: (name: string) => {
this.getters.floatingNodeByName(name).realHover().click({ force: true });
},
};
}

View file

@ -68,7 +68,10 @@ export class VariablesPage extends BasePage {
},
setRowValue: (row: Chainable<JQuery<HTMLElement>>, field: 'key' | 'value', value: string) => {
row.within(() => {
cy.getByTestId(`variable-row-${field}-input`).type('{selectAll}{del}').type(value);
cy.getByTestId(`variable-row-${field}-input`)
.find('input, textarea')
.type('{selectAll}{del}')
.type(value);
});
},
cancelRowEditing: (row: Chainable<JQuery<HTMLElement>>) => {

View file

@ -1,5 +1,6 @@
import { BasePage } from './base';
import { NodeCreator } from './features/node-creator';
import { clickContextMenuAction, getCanvasPane, openContextMenu } from '../composables/workflow';
import { META_KEY } from '../constants';
import type { OpenContextMenuOptions } from '../types';
import { getVisibleSelect } from '../utils';
@ -38,15 +39,7 @@ export class WorkflowPage extends BasePage {
nodeCreatorSearchBar: () => cy.getByTestId('node-creator-search-bar'),
nodeCreatorPlusButton: () => cy.getByTestId('node-creator-plus-button'),
canvasPlusButton: () => cy.getByTestId('canvas-plus-button'),
canvasNodes: () =>
cy.ifCanvasVersion(
() => cy.getByTestId('canvas-node'),
() =>
cy
.getByTestId('canvas-node')
.not('[data-node-type="n8n-nodes-internal.addNodes"]')
.not('[data-node-type="n8n-nodes-base.stickyNote"]'),
),
canvasNodes: () => cy.getByTestId('canvas-node'),
canvasNodeByName: (nodeName: string) =>
this.getters.canvasNodes().filter(`:contains(${nodeName})`),
nodeIssuesByName: (nodeName: string) =>
@ -58,13 +51,13 @@ export class WorkflowPage extends BasePage {
getEndpointSelector: (type: 'input' | 'output' | 'plus', nodeName: string, index = 0) => {
if (isCanvasV2()) {
if (type === 'input') {
return `[data-test-id="canvas-node-input-handle"][data-node-name="${nodeName}"][data-handle-index="${index}"]`;
return `[data-test-id="canvas-node-input-handle"][data-node-name="${nodeName}"][data-index="${index}"]`;
}
if (type === 'output') {
return `[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"][data-handle-index="${index}"]`;
return `[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"][data-index="${index}"]`;
}
if (type === 'plus') {
return `[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"][data-handle-index="${index}"] [data-test-id="canvas-handle-plus"] .clickable`;
return `[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"][data-index="${index}"] [data-test-id="canvas-handle-plus"]`;
}
}
return `[data-endpoint-name='${nodeName}'][data-endpoint-type='${type}'][data-input-index='${index}']`;
@ -81,7 +74,7 @@ export class WorkflowPage extends BasePage {
() =>
cy
.get(
`[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"] .clickable`,
`[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"]`,
)
.eq(index),
);
@ -103,14 +96,14 @@ export class WorkflowPage extends BasePage {
nodeConnections: () =>
cy.ifCanvasVersion(
() => cy.get('.jtk-connector'),
() => cy.getByTestId('edge-label-wrapper'),
() => cy.getByTestId('edge'),
),
zoomToFitButton: () => cy.getByTestId('zoom-to-fit'),
nodeEndpoints: () => cy.get('.jtk-endpoint-connected'),
disabledNodes: () =>
cy.ifCanvasVersion(
() => cy.get('.node-box.disabled'),
() => cy.get('[data-test-id*="node"][class*="disabled"]'),
() => cy.get('[data-canvas-node-render-type][class*="disabled"]'),
),
selectedNodes: () =>
cy.ifCanvasVersion(
@ -189,7 +182,7 @@ export class WorkflowPage extends BasePage {
),
() =>
cy.get(
`[data-test-id="edge-label-wrapper"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"]`,
`[data-test-id="edge"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"]`,
),
),
getConnectionActionsBetweenNodes: (sourceNodeName: string, targetNodeName: string) =>
@ -200,7 +193,7 @@ export class WorkflowPage extends BasePage {
),
() =>
cy.get(
`[data-test-id="edge-label-wrapper"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"] [data-test-id="canvas-edge-toolbar"]`,
`[data-test-id="edge-label"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"] [data-test-id="canvas-edge-toolbar"]`,
),
),
addStickyButton: () => cy.getByTestId('add-sticky-button'),
@ -288,71 +281,77 @@ export class WorkflowPage extends BasePage {
nodeTypeName?: string,
{ method = 'right-click', anchor = 'center' }: OpenContextMenuOptions = {},
) => {
const target = nodeTypeName
? this.getters.canvasNodeByName(nodeTypeName)
: this.getters.nodeViewBackground();
cy.ifCanvasVersion(
() => {
const target = nodeTypeName
? this.getters.canvasNodeByName(nodeTypeName)
: this.getters.nodeViewBackground();
if (method === 'right-click') {
target.rightclick(nodeTypeName ? anchor : 'topLeft', { force: true });
} else {
target.realHover();
target.find('[data-test-id="overflow-node-button"]').click({ force: true });
}
if (method === 'right-click') {
target.rightclick(nodeTypeName ? anchor : 'topLeft', { force: true });
} else {
target.realHover();
target.find('[data-test-id="overflow-node-button"]').click({ force: true });
}
},
() => {
openContextMenu(nodeTypeName, { method, anchor });
},
);
},
openNode: (nodeTypeName: string) => {
this.getters.canvasNodeByName(nodeTypeName).first().dblclick();
},
duplicateNode: (nodeTypeName: string) => {
this.actions.openContextMenu(nodeTypeName);
this.actions.contextMenuAction('duplicate');
clickContextMenuAction('duplicate');
},
deleteNodeFromContextMenu: (nodeTypeName: string) => {
this.actions.openContextMenu(nodeTypeName);
this.actions.contextMenuAction('delete');
clickContextMenuAction('delete');
},
executeNode: (nodeTypeName: string, options?: OpenContextMenuOptions) => {
this.actions.openContextMenu(nodeTypeName, options);
this.actions.contextMenuAction('execute');
clickContextMenuAction('execute');
},
addStickyFromContextMenu: () => {
this.actions.openContextMenu();
this.actions.contextMenuAction('add_sticky');
clickContextMenuAction('add_sticky');
},
renameNode: (nodeTypeName: string) => {
this.actions.openContextMenu(nodeTypeName);
this.actions.contextMenuAction('rename');
clickContextMenuAction('rename');
},
copyNode: (nodeTypeName: string) => {
this.actions.openContextMenu(nodeTypeName);
this.actions.contextMenuAction('copy');
clickContextMenuAction('copy');
},
contextMenuAction: (action: string) => {
this.getters.contextMenuAction(action).click();
},
disableNode: (nodeTypeName: string) => {
this.actions.openContextMenu(nodeTypeName);
this.actions.contextMenuAction('toggle_activation');
clickContextMenuAction('toggle_activation');
},
pinNode: (nodeTypeName: string) => {
this.actions.openContextMenu(nodeTypeName);
this.actions.contextMenuAction('toggle_pin');
clickContextMenuAction('toggle_pin');
},
openNodeFromContextMenu: (nodeTypeName: string) => {
this.actions.openContextMenu(nodeTypeName, { method: 'overflow-button' });
this.actions.contextMenuAction('open');
clickContextMenuAction('open');
},
selectAllFromContextMenu: () => {
this.actions.openContextMenu();
this.actions.contextMenuAction('select_all');
clickContextMenuAction('select_all');
},
deselectAll: () => {
cy.ifCanvasVersion(
() => {
this.actions.openContextMenu();
this.actions.contextMenuAction('deselect_all');
clickContextMenuAction('deselect_all');
},
// rightclick doesn't work with vueFlow canvas
() => this.getters.nodeViewBackground().click('topLeft'),
() => getCanvasPane().click('topLeft'),
);
},
openExpressionEditorModal: () => {
@ -431,7 +430,7 @@ export class WorkflowPage extends BasePage {
pinchToZoom: (steps: number, mode: 'zoomIn' | 'zoomOut' = 'zoomIn') => {
cy.window().then((win) => {
// Pinch-to-zoom simulates a 'wheel' event with ctrlKey: true (same as zooming by scrolling)
this.getters.nodeView().trigger('wheel', {
getCanvasPane().trigger('wheel', {
force: true,
bubbles: true,
ctrlKey: true,

View file

@ -45,19 +45,23 @@ switch (scenario) {
startCommand: 'start',
url: 'http://localhost:5678/favicon.ico',
testCommand: 'cypress open',
customEnv: {
CYPRESS_NODE_VIEW_VERSION: 2,
},
});
break;
case 'dev':
case 'dev:v1':
runTests({
startCommand: 'develop',
url: 'http://localhost:8080/favicon.ico',
testCommand: 'cypress open',
customEnv: {
CYPRESS_NODE_VIEW_VERSION: 1,
CYPRESS_BASE_URL: 'http://localhost:8080',
},
});
break;
case 'dev:v2':
case 'dev':
runTests({
startCommand: 'develop',
url: 'http://localhost:8080/favicon.ico',
@ -76,6 +80,9 @@ switch (scenario) {
startCommand: 'start',
url: 'http://localhost:5678/favicon.ico',
testCommand: `cypress run --headless ${specParam}`,
customEnv: {
CYPRESS_NODE_VIEW_VERSION: 2,
},
});
break;
default:

View file

@ -77,7 +77,7 @@ Cypress.Commands.add('signin', ({ email, password }) => {
// @TODO Remove this once the switcher is removed
cy.window().then((win) => {
win.localStorage.setItem('NodeView.migrated', 'true');
win.localStorage.setItem('NodeView.migrated.release', 'true');
win.localStorage.setItem('NodeView.switcher.discovered.beta', 'true');
const nodeViewVersion = Cypress.env('NODE_VIEW_VERSION');
@ -172,6 +172,7 @@ Cypress.Commands.add('drag', (selector, pos, options) => {
};
if (options?.realMouse) {
element.realMouseDown();
element.realMouseMove(0, 0);
element.realMouseMove(newPosition.x, newPosition.y);
element.realMouseUp();
} else {
@ -218,8 +219,15 @@ Cypress.Commands.add('draganddrop', (draggableSelector, droppableSelector, optio
const pageY = coords.top + coords.height / 2;
if (draggableSelector) {
// We can't use realMouseDown here because it hangs headless run
cy.get(draggableSelector).trigger('mousedown');
cy.ifCanvasVersion(
() => {
// We can't use realMouseDown here because it hangs headless run
cy.get(draggableSelector).trigger('mousedown');
},
() => {
cy.get(draggableSelector).realMouseDown();
},
);
}
// We don't chain these commands to make sure cy.get is re-trying correctly
cy.get(droppableSelector).realMouseMove(0, 0);

View file

@ -38,7 +38,21 @@ beforeEach(() => {
data: { status: 'success', message: 'Tested successfully' },
}).as('credentialTest');
cy.intercept('POST', '/rest/license/renew', {});
cy.intercept('POST', '/rest/license/renew', {
data: {
usage: {
activeWorkflowTriggers: {
limit: -1,
value: 0,
warningThreshold: 0.8,
},
},
license: {
planId: '',
planName: 'Community',
},
},
});
cy.intercept({ pathname: '/api/health' }, { status: 'OK' }).as('healthCheck');
cy.intercept({ pathname: '/api/versions/*' }, [

View file

@ -16,7 +16,7 @@ RUN apk add --update git openssh graphicsmagick tini tzdata ca-certificates libc
# Update npm and install full-uci
COPY .npmrc /usr/local/etc/npmrc
RUN npm install -g npm@9.9.2 full-icu@1.5.0
RUN npm install -g npm@9.9.2 corepack@0.31 full-icu@1.5.0
# Activate corepack, and install pnpm
WORKDIR /tmp

View file

@ -73,7 +73,7 @@ docker run -it --rm \
-p 5678:5678 \
-v ~/.n8n:/home/node/.n8n \
docker.n8n.io/n8nio/n8n \
n8n start --tunnel
start --tunnel
```
## Persist data

View file

@ -4,7 +4,11 @@
"runner-type": "javascript",
"workdir": "/home/node",
"command": "/usr/local/bin/node",
"args": ["/usr/local/lib/node_modules/n8n/node_modules/@n8n/task-runner/dist/start.js"],
"args": [
"--disallow-code-generation-from-strings",
"--disable-proto=delete",
"/usr/local/lib/node_modules/n8n/node_modules/@n8n/task-runner/dist/start.js"
],
"allowed-env": [
"PATH",
"GENERIC_TIMEZONE",

View file

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

View file

@ -1,12 +1,12 @@
{
"name": "n8n-monorepo",
"version": "1.74.1",
"version": "1.78.0",
"private": true,
"engines": {
"node": ">=20.15",
"pnpm": ">=9.15"
},
"packageManager": "pnpm@9.15.1",
"packageManager": "pnpm@9.15.5",
"scripts": {
"prepare": "node scripts/prepare.mjs",
"preinstall": "node scripts/block-npm-install.js",
@ -21,7 +21,7 @@
"dev:fe": "run-p start \"dev:fe:editor --filter=n8n-design-system\"",
"dev:fe:editor": "turbo run dev --parallel --env-mode=loose --filter=n8n-editor-ui",
"dev:e2e": "cd cypress && pnpm run test:e2e:dev",
"dev:e2e:v2": "cd cypress && pnpm run test:e2e:dev:v2",
"dev:e2e:v1": "cd cypress && pnpm run test:e2e:dev:v1",
"dev:e2e:server": "run-p start dev:fe:editor",
"clean": "turbo run clean --parallel",
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",
@ -58,8 +58,7 @@
"jest-mock": "^29.6.2",
"jest-mock-extended": "^3.0.4",
"lefthook": "^1.7.15",
"loader": "^2.1.1",
"nock": "^13.3.2",
"nock": "^14.0.0",
"nodemon": "^3.0.1",
"npm-run-all2": "^7.0.2",
"p-limit": "^3.1.0",
@ -91,6 +90,7 @@
"ws": ">=8.17.1"
},
"patchedDependencies": {
"bull@4.12.1": "patches/bull@4.12.1.patch",
"pkce-challenge@3.0.0": "patches/pkce-challenge@3.0.0.patch",
"pyodide@0.23.4": "patches/pyodide@0.23.4.patch",
"@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch",

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/api-types",
"version": "0.12.0",
"version": "0.14.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -0,0 +1,14 @@
/** Unix timestamp. Seconds since epoch */
export type UnixTimestamp = number | null;
export type ApiKey = {
id: string;
label: string;
apiKey: string;
createdAt: string;
updatedAt: string;
/** Null if API key never expires */
expiresAt: UnixTimestamp | null;
};
export type ApiKeyWithRawValue = ApiKey & { rawApiKey: string };

View file

@ -0,0 +1,53 @@
import { CreateApiKeyRequestDto } from '../create-api-key-request.dto';
describe('CreateApiKeyRequestDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'expiresAt in the future',
expiresAt: Date.now() / 1000 + 1000,
},
{
name: 'expiresAt null',
expiresAt: null,
},
])('should succeed validation for $name', ({ expiresAt }) => {
const result = CreateApiKeyRequestDto.safeParse({ label: 'valid', expiresAt });
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'expiresAt in the past',
expiresAt: Date.now() / 1000 - 1000,
expectedErrorPath: ['expiresAt'],
},
{
name: 'expiresAt with string',
expiresAt: 'invalid',
expectedErrorPath: ['expiresAt'],
},
{
name: 'expiresAt with []',
expiresAt: [],
expectedErrorPath: ['expiresAt'],
},
{
name: 'expiresAt with {}',
expiresAt: {},
expectedErrorPath: ['expiresAt'],
},
])('should fail validation for $name', ({ expiresAt, expectedErrorPath }) => {
const result = CreateApiKeyRequestDto.safeParse({ label: 'valid', expiresAt });
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,40 @@
import { UpdateApiKeyRequestDto } from '../update-api-key-request.dto';
describe('UpdateApiKeyRequestDto', () => {
describe('Valid requests', () => {
test('should allow valid label', () => {
const result = UpdateApiKeyRequestDto.safeParse({
label: 'valid label',
});
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'empty label',
label: '',
expectedErrorPath: ['label'],
},
{
name: 'label exceeding 50 characters',
label: '2mWMfsrvAmneWluS8IbezaIHZOu2mWMfsrvAmneWluS8IbezaIa',
expectedErrorPath: ['label'],
},
{
name: 'label with xss injection',
label: '<script>alert("xss");new label</script>',
expectedErrorPath: ['label'],
},
])('should fail validation for $name', ({ label, expectedErrorPath }) => {
const result = UpdateApiKeyRequestDto.safeParse({ label });
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,15 @@
import { z } from 'zod';
import { UpdateApiKeyRequestDto } from './update-api-key-request.dto';
const isTimeNullOrInFuture = (value: number | null) => {
if (!value) return true;
return value > Date.now() / 1000;
};
export class CreateApiKeyRequestDto extends UpdateApiKeyRequestDto.extend({
expiresAt: z
.number()
.nullable()
.refine(isTimeNullOrInFuture, { message: 'Expiration date must be in the future or null' }),
}) {}

View file

@ -0,0 +1,13 @@
import xss from 'xss';
import { z } from 'zod';
import { Z } from 'zod-class';
const xssCheck = (value: string) =>
value ===
xss(value, {
whiteList: {},
});
export class UpdateApiKeyRequestDto extends Z.class({
label: z.string().max(50).min(1).refine(xssCheck),
}) {}

View file

@ -21,6 +21,10 @@ export { ForgotPasswordRequestDto } from './password-reset/forgot-password-reque
export { ResolvePasswordTokenQueryDto } from './password-reset/resolve-password-token-query.dto';
export { ChangePasswordRequestDto } from './password-reset/change-password-request.dto';
export { CreateProjectDto } from './project/create-project.dto';
export { UpdateProjectDto } from './project/update-project.dto';
export { DeleteProjectDto } from './project/delete-project.dto';
export { SamlAcsDto } from './saml/saml-acs.dto';
export { SamlPreferences } from './saml/saml-preferences.dto';
export { SamlToggleDto } from './saml/saml-toggle.dto';
@ -32,8 +36,18 @@ export { UserUpdateRequestDto } from './user/user-update-request.dto';
export { CommunityRegisteredRequestDto } from './license/community-registered-request.dto';
export { PullWorkFolderRequestDto } from './source-control/pull-work-folder-request.dto';
export { PushWorkFolderRequestDto } from './source-control/push-work-folder-request.dto';
export { VariableListRequestDto } from './variables/variables-list-request.dto';
export { CredentialsGetOneRequestQuery } from './credentials/credentials-get-one-request.dto';
export { CredentialsGetManyRequestQuery } from './credentials/credentials-get-many-request.dto';
export { ImportWorkflowFromUrlDto } from './workflows/import-workflow-from-url.dto';
export { ManualRunQueryDto } from './workflows/manual-run-query.dto';
export { CreateOrUpdateTagRequestDto } from './tag/create-or-update-tag-request.dto';
export { RetrieveTagQueryDto } from './tag/retrieve-tag-query.dto';
export { UpdateApiKeyRequestDto } from './api-keys/update-api-key-request.dto';
export { CreateApiKeyRequestDto } from './api-keys/create-api-key-request.dto';

View file

@ -0,0 +1,75 @@
import { CreateProjectDto } from '../create-project.dto';
describe('CreateProjectDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'with just the name',
request: {
name: 'My Awesome Project',
},
},
{
name: 'with name and emoji icon',
request: {
name: 'My Awesome Project',
icon: {
type: 'emoji',
value: '🚀',
},
},
},
{
name: 'with name and regular icon',
request: {
name: 'My Awesome Project',
icon: {
type: 'icon',
value: 'blah',
},
},
},
])('should validate $name', ({ request }) => {
const result = CreateProjectDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing name',
request: { icon: { type: 'emoji', value: '🚀' } },
expectedErrorPath: ['name'],
},
{
name: 'empty name',
request: { name: '', icon: { type: 'emoji', value: '🚀' } },
expectedErrorPath: ['name'],
},
{
name: 'name too long',
request: { name: 'a'.repeat(256), icon: { type: 'emoji', value: '🚀' } },
expectedErrorPath: ['name'],
},
{
name: 'invalid icon type',
request: { name: 'My Awesome Project', icon: { type: 'invalid', value: '🚀' } },
expectedErrorPath: ['icon', 'type'],
},
{
name: 'invalid icon value',
request: { name: 'My Awesome Project', icon: { type: 'emoji', value: '' } },
expectedErrorPath: ['icon', 'value'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = CreateProjectDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,121 @@
import { UpdateProjectDto } from '../update-project.dto';
describe('UpdateProjectDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'with just the name',
request: {
name: 'My Updated Project',
},
},
{
name: 'with name and emoji icon',
request: {
name: 'My Updated Project',
icon: {
type: 'emoji',
value: '🚀',
},
},
},
{
name: 'with name and regular icon',
request: {
name: 'My Updated Project',
icon: {
type: 'icon',
value: 'blah',
},
},
},
{
name: 'with relations',
request: {
relations: [
{
userId: 'user-123',
role: 'project:admin',
},
],
},
},
{
name: 'with all fields',
request: {
name: 'My Updated Project',
icon: {
type: 'emoji',
value: '🚀',
},
relations: [
{
userId: 'user-123',
role: 'project:admin',
},
],
},
},
])('should validate $name', ({ request }) => {
const result = UpdateProjectDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid name type',
request: { name: 123 },
expectedErrorPath: ['name'],
},
{
name: 'name too long',
request: { name: 'a'.repeat(256) },
expectedErrorPath: ['name'],
},
{
name: 'invalid icon type',
request: { icon: { type: 'invalid', value: '🚀' } },
expectedErrorPath: ['icon', 'type'],
},
{
name: 'invalid icon value',
request: { icon: { type: 'emoji', value: '' } },
expectedErrorPath: ['icon', 'value'],
},
{
name: 'invalid relations userId',
request: {
relations: [
{
userId: 123,
role: 'project:admin',
},
],
},
expectedErrorPath: ['relations', 0, 'userId'],
},
{
name: 'invalid relations role',
request: {
relations: [
{
userId: 'user-123',
role: 'invalid-role',
},
],
},
expectedErrorPath: ['relations', 0, 'role'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = UpdateProjectDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,8 @@
import { Z } from 'zod-class';
import { projectIconSchema, projectNameSchema } from '../../schemas/project.schema';
export class CreateProjectDto extends Z.class({
name: projectNameSchema,
icon: projectIconSchema.optional(),
}) {}

View file

@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class DeleteProjectDto extends Z.class({
transferId: z.string().optional(),
}) {}

View file

@ -0,0 +1,14 @@
import { z } from 'zod';
import { Z } from 'zod-class';
import {
projectIconSchema,
projectNameSchema,
projectRelationSchema,
} from '../../schemas/project.schema';
export class UpdateProjectDto extends Z.class({
name: projectNameSchema.optional(),
icon: projectIconSchema.optional(),
relations: z.array(projectRelationSchema).optional(),
}) {}

View file

@ -0,0 +1,38 @@
import { PullWorkFolderRequestDto } from '../pull-work-folder-request.dto';
describe('PullWorkFolderRequestDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'with force',
request: { force: true },
},
{
name: 'without force',
request: {},
},
])('should validate $name', ({ request }) => {
const result = PullWorkFolderRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid force type',
request: {
force: 'true', // Should be boolean
},
expectedErrorPath: ['force'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = PullWorkFolderRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,112 @@
import { PushWorkFolderRequestDto } from '../push-work-folder-request.dto';
describe('PushWorkFolderRequestDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'complete valid push request with all fields',
request: {
force: true,
fileNames: [
{
file: 'file1.json',
id: '1',
name: 'File 1',
type: 'workflow',
status: 'modified',
location: 'local',
conflict: false,
updatedAt: '2023-10-01T12:00:00Z',
pushed: true,
},
],
message: 'Initial commit',
},
},
{
name: 'push request with only required fields',
request: {
fileNames: [
{
file: 'file2.json',
id: '2',
name: 'File 2',
type: 'credential',
status: 'new',
location: 'remote',
conflict: true,
updatedAt: '2023-10-02T12:00:00Z',
},
],
},
},
])('should validate $name', ({ request }) => {
const result = PushWorkFolderRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing required fileNames field',
request: {
force: true,
message: 'Initial commit',
},
expectedErrorPath: ['fileNames'],
},
{
name: 'invalid fileNames type',
request: {
fileNames: 'not-an-array', // Should be an array
},
expectedErrorPath: ['fileNames'],
},
{
name: 'invalid fileNames array element',
request: {
fileNames: [
{
file: 'file4.json',
id: '4',
name: 'File 4',
type: 'invalid-type', // Invalid type
status: 'modified',
location: 'local',
conflict: false,
updatedAt: '2023-10-04T12:00:00Z',
},
],
},
expectedErrorPath: ['fileNames', 0, 'type'],
},
{
name: 'invalid force type',
request: {
force: 'true', // Should be boolean
fileNames: [
{
file: 'file5.json',
id: '5',
name: 'File 5',
type: 'workflow',
status: 'modified',
location: 'local',
conflict: false,
updatedAt: '2023-10-05T12:00:00Z',
},
],
},
expectedErrorPath: ['force'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = PushWorkFolderRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class PullWorkFolderRequestDto extends Z.class({
force: z.boolean().optional(),
}) {}

View file

@ -0,0 +1,10 @@
import { z } from 'zod';
import { Z } from 'zod-class';
import { SourceControlledFileSchema } from '../../schemas/source-controlled-file.schema';
export class PushWorkFolderRequestDto extends Z.class({
force: z.boolean().optional(),
commitMessage: z.string().optional(),
fileNames: z.array(SourceControlledFileSchema),
}) {}

View file

@ -0,0 +1,37 @@
import { CreateOrUpdateTagRequestDto } from '../create-or-update-tag-request.dto';
describe('CreateOrUpdateTagRequestDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'valid name',
request: {
name: 'tag-name',
},
},
])('should validate $name', ({ request }) => {
const result = CreateOrUpdateTagRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'empty tag name',
request: {
name: '',
},
expectedErrorPath: ['name'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = CreateOrUpdateTagRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,64 @@
import { RetrieveTagQueryDto } from '../retrieve-tag-query.dto';
describe('RetrieveTagQueryDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'with "true"',
request: {
withUsageCount: 'true',
},
},
{
name: 'with "false"',
request: {
withUsageCount: 'false',
},
},
])('should pass validation for withUsageCount $name', ({ request }) => {
const result = RetrieveTagQueryDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'with number',
request: {
withUsageCount: 1,
},
expectedErrorPath: ['withUsageCount'],
},
{
name: 'with boolean (true) ',
request: {
withUsageCount: true,
},
expectedErrorPath: ['withUsageCount'],
},
{
name: 'with boolean (false)',
request: {
withUsageCount: false,
},
expectedErrorPath: ['withUsageCount'],
},
{
name: 'with invalid string',
request: {
withUsageCount: 'invalid',
},
expectedErrorPath: ['withUsageCount'],
},
])('should fail validation for withUsageCount $name', ({ request, expectedErrorPath }) => {
const result = RetrieveTagQueryDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,6 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class CreateOrUpdateTagRequestDto extends Z.class({
name: z.string().trim().min(1),
}) {}

View file

@ -0,0 +1,7 @@
import { Z } from 'zod-class';
import { booleanFromString } from '../../schemas/booleanFromString';
export class RetrieveTagQueryDto extends Z.class({
withUsageCount: booleanFromString.optional().default('false'),
}) {}

View file

@ -0,0 +1,47 @@
import { ManualRunQueryDto } from '../manual-run-query.dto';
describe('ManualRunQueryDto', () => {
describe('Valid requests', () => {
test.each([
{ name: 'version number 1', partialExecutionVersion: '1' },
{ name: 'version number 2', partialExecutionVersion: '2' },
{ name: 'missing version' },
])('should validate $name', ({ partialExecutionVersion }) => {
const result = ManualRunQueryDto.safeParse({ partialExecutionVersion });
if (!result.success) {
return fail('expected validation to succeed');
}
expect(result.success).toBe(true);
expect(typeof result.data.partialExecutionVersion).toBe('number');
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid version 0',
partialExecutionVersion: '0',
expectedErrorPath: ['partialExecutionVersion'],
},
{
name: 'invalid type (boolean)',
partialExecutionVersion: true,
expectedErrorPath: ['partialExecutionVersion'],
},
{
name: 'invalid type (number)',
partialExecutionVersion: 1,
expectedErrorPath: ['partialExecutionVersion'],
},
])('should fail validation for $name', ({ partialExecutionVersion, expectedErrorPath }) => {
const result = ManualRunQueryDto.safeParse({ partialExecutionVersion });
if (result.success) {
return fail('expected validation to fail');
}
expect(result.error.issues[0].path).toEqual(expectedErrorPath);
});
});
});

View file

@ -0,0 +1,9 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class ManualRunQueryDto extends Z.class({
partialExecutionVersion: z
.enum(['1', '2'])
.default('1')
.transform((version) => Number.parseInt(version) as 1 | 2),
}) {}

View file

@ -87,6 +87,7 @@ export interface FrontendSettings {
};
};
publicApi: {
apiKeysPerUserLimit: number;
enabled: boolean;
latestVersion: number;
path: string;
@ -177,4 +178,8 @@ export interface FrontendSettings {
};
betaFeatures: FrontendBetaFeatures[];
easyAIWorkflowOnboarded: boolean;
partialExecution: {
version: 1 | 2;
enforce: boolean;
};
}

View file

@ -4,9 +4,24 @@ export type * from './push';
export type * from './scaling';
export type * from './frontend-settings';
export type * from './user';
export type * from './api-keys';
export type { Collaborator } from './push/collaboration';
export type { SendWorkerStatusMessage } from './push/worker';
export type { BannerName } from './schemas/bannerName.schema';
export { passwordSchema } from './schemas/password.schema';
export type {
ProjectType,
ProjectIcon,
ProjectRole,
ProjectRelation,
} from './schemas/project.schema';
export {
type SourceControlledFile,
SOURCE_CONTROL_FILE_LOCATION,
SOURCE_CONTROL_FILE_STATUS,
SOURCE_CONTROL_FILE_TYPE,
} from './schemas/source-controlled-file.schema';

View file

@ -52,6 +52,17 @@ type NodeExecuteAfter = {
executionId: string;
nodeName: string;
data: ITaskData;
/**
* When a worker relays updates about a manual execution to main, if the
* payload size is above a limit, we send only a placeholder to the client.
* Later we fetch the entire execution data and fill in any placeholders.
*
* When sending a placheolder, we also send the number of output items, so
* the client knows ahead of time how many items are there, to prevent the
* items count from jumping up when the execution finishes.
*/
itemCount?: number;
};
};

View file

@ -6,7 +6,7 @@ export type RunningJobSummary = {
workflowName: string;
mode: WorkflowExecuteMode;
startedAt: Date;
retryOf: string;
retryOf?: string;
status: ExecutionStatus;
};

View file

@ -0,0 +1,105 @@
import {
projectNameSchema,
projectTypeSchema,
projectIconSchema,
projectRoleSchema,
projectRelationSchema,
} from '../project.schema';
describe('project.schema', () => {
describe('projectNameSchema', () => {
test.each([
{ name: 'valid name', value: 'My Project', expected: true },
{ name: 'empty name', value: '', expected: false },
{ name: 'name too long', value: 'a'.repeat(256), expected: false },
])('should validate $name', ({ value, expected }) => {
const result = projectNameSchema.safeParse(value);
expect(result.success).toBe(expected);
});
});
describe('projectTypeSchema', () => {
test.each([
{ name: 'valid type: personal', value: 'personal', expected: true },
{ name: 'valid type: team', value: 'team', expected: true },
{ name: 'invalid type', value: 'invalid', expected: false },
])('should validate $name', ({ value, expected }) => {
const result = projectTypeSchema.safeParse(value);
expect(result.success).toBe(expected);
});
});
describe('projectIconSchema', () => {
test.each([
{
name: 'valid emoji icon',
value: { type: 'emoji', value: '🚀' },
expected: true,
},
{
name: 'valid icon',
value: { type: 'icon', value: 'blah' },
expected: true,
},
{
name: 'invalid icon type',
value: { type: 'invalid', value: '🚀' },
expected: false,
},
{
name: 'empty icon value',
value: { type: 'emoji', value: '' },
expected: false,
},
])('should validate $name', ({ value, expected }) => {
const result = projectIconSchema.safeParse(value);
expect(result.success).toBe(expected);
});
});
describe('projectRoleSchema', () => {
test.each([
{ name: 'valid role: project:personalOwner', value: 'project:personalOwner', expected: true },
{ name: 'valid role: project:admin', value: 'project:admin', expected: true },
{ name: 'valid role: project:editor', value: 'project:editor', expected: true },
{ name: 'valid role: project:viewer', value: 'project:viewer', expected: true },
{ name: 'invalid role', value: 'invalid-role', expected: false },
])('should validate $name', ({ value, expected }) => {
const result = projectRoleSchema.safeParse(value);
expect(result.success).toBe(expected);
});
});
describe('projectRelationSchema', () => {
test.each([
{
name: 'valid relation',
value: { userId: 'user-123', role: 'project:admin' },
expected: true,
},
{
name: 'invalid userId type',
value: { userId: 123, role: 'project:admin' },
expected: false,
},
{
name: 'invalid role',
value: { userId: 'user-123', role: 'invalid-role' },
expected: false,
},
{
name: 'missing userId',
value: { role: 'project:admin' },
expected: false,
},
{
name: 'missing role',
value: { userId: 'user-123' },
expected: false,
},
])('should validate $name', ({ value, expected }) => {
const result = projectRelationSchema.safeParse(value);
expect(result.success).toBe(expected);
});
});
});

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