Merge remote-tracking branch 'origin/master' into export-import

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-11-11 14:51:25 +01:00
commit 5eb9413469
No known key found for this signature in database
1137 changed files with 44168 additions and 15767 deletions

View file

@ -11,3 +11,8 @@
# refactor: Run lintfix (no-changelog) (#7537)
62c096710fab2f7e886518abdbded34b55e93f62
# refactor: Move test files alongside tested files (#11504)
7e58fc4fec468aca0b45d5bfe6150e1af632acbc
f32b13c6ed078be042a735bc8621f27e00dc3116

View file

@ -11,6 +11,8 @@ Photos and videos are recommended.
Include links to **Linear ticket** or Github issue or Community forum post.
Important in order to close *automatically* and provide context to reviewers.
-->
<!-- Use "closes #<issue-number>", "fixes #<issue-number>", or "resolves #<issue-number>" to automatically close issues when the PR is merged. -->
## Review / Merge checklist

View file

@ -65,6 +65,7 @@ jobs:
continue-on-error: true
with:
workingDir: packages/design-system
onlyChanged: true
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
exitZeroOnChanges: false

View file

@ -49,6 +49,9 @@ jobs:
with:
ref: refs/pull/${{ github.event.pull_request.number }}/merge
cacheKey: ${{ github.sha }}-base:build
collectCoverage: true
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
lint:
name: Lint

View file

@ -41,6 +41,11 @@ 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.'
@ -160,6 +165,7 @@ jobs:
spec: '${{ inputs.spec }}'
env:
NODE_OPTIONS: --dns-result-order=ipv4first
CYPRESS_NODE_VIEW_VERSION: ${{ inputs.node_view_version }}
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
E2E_TESTS: true

View file

@ -27,6 +27,11 @@ 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:
@ -46,6 +51,7 @@ 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

@ -38,6 +38,12 @@ jobs:
- name: Build
run: pnpm build
- name: Cache build artifacts
uses: actions/cache/save@v4.0.0
with:
path: ./packages/**/dist
key: ${{ github.sha }}-release:build
- name: Dry-run publishing
run: pnpm publish -r --no-git-checks --dry-run
@ -119,6 +125,40 @@ jobs:
makeLatest: false
body: ${{github.event.pull_request.body}}
create-sentry-release:
name: Create a Sentry Release
needs: [publish-to-npm, publish-to-docker-hub]
runs-on: ubuntu-latest
if: github.event.pull_request.merged == true
timeout-minutes: 5
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
steps:
- uses: actions/checkout@v4.1.1
- name: Restore cached build artifacts
uses: actions/cache/restore@v4.0.0
with:
path: ./packages/**/dist
key: ${{ github.sha }}-release:build
- name: Create a frontend release
uses: getsentry/action-release@v1.7.0
continue-on-error: true
with:
projects: ${{ secrets.SENTRY_FRONTEND_PROJECT }}
version: ${{ needs.publish-to-npm.outputs.release }}
sourcemaps: packages/editor-ui/dist
- name: Create a backend release
uses: getsentry/action-release@v1.7.0
continue-on-error: true
with:
projects: ${{ secrets.SENTRY_BACKEND_PROJECT }}
version: ${{ needs.publish-to-npm.outputs.release }}
sourcemaps: packages/cli/dist packages/core/dist packages/nodes-base/dist packages/@n8n/n8n-nodes-langchain/dist
trigger-release-note:
name: Trigger a release note
needs: [publish-to-npm, create-github-release]

View file

@ -7,6 +7,7 @@
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode",
"mjmlio.vscode-mjml",
"Vue.volar"
"Vue.volar",
"vitest.explorer"
]
}

View file

@ -1,3 +1,174 @@
# [1.67.0](https://github.com/n8n-io/n8n/compare/n8n@1.66.0...n8n@1.67.0) (2024-11-06)
### Bug Fixes
* Bring back nodes panel telemetry events ([#11456](https://github.com/n8n-io/n8n/issues/11456)) ([130c942](https://github.com/n8n-io/n8n/commit/130c942f633788d1b2f937d6fea342d4450c6e3d))
* **core:** Account for double quotes in instance base URL ([#11495](https://github.com/n8n-io/n8n/issues/11495)) ([c5191e6](https://github.com/n8n-io/n8n/commit/c5191e697a9a9ebfa2b67587cd01b5835ebf6ea8))
* **core:** Do not delete waiting executions when saving of successful executions is disabled ([#11458](https://github.com/n8n-io/n8n/issues/11458)) ([e8757e5](https://github.com/n8n-io/n8n/commit/e8757e58f69e091ac3d2a2f8e8c8e33ac57c1e47))
* **core:** Don't send a `executionFinished` event to the browser with no run data if the execution has already been cleaned up ([#11502](https://github.com/n8n-io/n8n/issues/11502)) ([d1153f5](https://github.com/n8n-io/n8n/commit/d1153f51e80911cbc8f34ba5f038f349b75295c3))
* **core:** Include `projectId` in range query middleware ([#11590](https://github.com/n8n-io/n8n/issues/11590)) ([a6070af](https://github.com/n8n-io/n8n/commit/a6070afdda29631fd36e5213f52bf815268bcda4))
* **core:** Save exeution progress for waiting executions, even when progress saving is disabled ([#11535](https://github.com/n8n-io/n8n/issues/11535)) ([6b9353c](https://github.com/n8n-io/n8n/commit/6b9353c80f61ab36945fff434d98242dc1cab7b3))
* **core:** Use the correct docs URL for regular nodes when used as tools ([#11529](https://github.com/n8n-io/n8n/issues/11529)) ([a092b8e](https://github.com/n8n-io/n8n/commit/a092b8e972e1253d92df416f19096a045858e7c1))
* **Edit Image Node:** Fix Text operation by setting Arial as default font ([#11125](https://github.com/n8n-io/n8n/issues/11125)) ([60c1ace](https://github.com/n8n-io/n8n/commit/60c1ace64be29d651ce7b777fbb576598e38b9d7))
* **editor:** Auto focus first fields on SignIn, SignUp and ForgotMyPassword views ([#11445](https://github.com/n8n-io/n8n/issues/11445)) ([5b5bd72](https://github.com/n8n-io/n8n/commit/5b5bd7291dde17880b7699f7e6832938599ffd8f))
* **editor:** Do not overwrite the webhookId in the new canvas ([#11562](https://github.com/n8n-io/n8n/issues/11562)) ([dfd785b](https://github.com/n8n-io/n8n/commit/dfd785bc0894257eb6e62b0dd8f71248c27aae53))
* **editor:** Ensure Enter key on Cancel button correctly cancels node rename ([#11563](https://github.com/n8n-io/n8n/issues/11563)) ([be05ae3](https://github.com/n8n-io/n8n/commit/be05ae36e7790156cb48b317fc254ae46a3b2d8c))
* **editor:** Fix emitting `n8nReady` notification via `postmessage` on new canvas ([#11558](https://github.com/n8n-io/n8n/issues/11558)) ([463d101](https://github.com/n8n-io/n8n/commit/463d101f3592e6df4afd66c4d0fde0cb4aec34cc))
* **editor:** Fix run index input for RunData view in sub-nodes ([#11538](https://github.com/n8n-io/n8n/issues/11538)) ([434d31c](https://github.com/n8n-io/n8n/commit/434d31ce928342d52b6ab8b78639afd7829216d4))
* **editor:** Fix selected credential being overwritten in NDV ([#11496](https://github.com/n8n-io/n8n/issues/11496)) ([a26c0e2](https://github.com/n8n-io/n8n/commit/a26c0e2c3c7da87bfaba9737a967aa0070810d85))
* **editor:** Keep workflow pristine after load on new canvas ([#11579](https://github.com/n8n-io/n8n/issues/11579)) ([7254359](https://github.com/n8n-io/n8n/commit/7254359855b89769613cd5cc24dbb4f45a7cc76f))
* Show Pinned data in demo mode ([#11490](https://github.com/n8n-io/n8n/issues/11490)) ([ca2a583](https://github.com/n8n-io/n8n/commit/ca2a583b5cbb0cba3ecb694261806de16547aa91))
* Toast not aligned to the bottom when AI assistant disable ([#11549](https://github.com/n8n-io/n8n/issues/11549)) ([e80f7e0](https://github.com/n8n-io/n8n/commit/e80f7e0a02a972379f73af6a44de11768081086e))
### Features
* Add Rapid7 InsightVm credentials ([#11462](https://github.com/n8n-io/n8n/issues/11462)) ([46eceab](https://github.com/n8n-io/n8n/commit/46eceabc27ac219b11b85c16c533a2cff848c5dd))
* **AI Transform Node:** UX improvements ([#11280](https://github.com/n8n-io/n8n/issues/11280)) ([8a48407](https://github.com/n8n-io/n8n/commit/8a484077af3d3e1fe2d1b90b1ea9edf4ba41fcb6))
* **Anthropic Chat Model Node:** Add support for Haiku 3.5 ([#11551](https://github.com/n8n-io/n8n/issues/11551)) ([8b39825](https://github.com/n8n-io/n8n/commit/8b398256a81594a52f20f8eb8adf8ff205209bc1))
* **Convert to File Node:** Add delimiter convert to csv ([#11556](https://github.com/n8n-io/n8n/issues/11556)) ([63d454b](https://github.com/n8n-io/n8n/commit/63d454b776c092ff8c6c521a7e083774adb8f649))
* **editor:** Update panning and selection keybindings on new canvas ([#11534](https://github.com/n8n-io/n8n/issues/11534)) ([5e2e205](https://github.com/n8n-io/n8n/commit/5e2e205394adf76faf02aee2d4f21df71848e1d4))
* **Gmail Trigger Node:** Add filter option to include drafts ([#11441](https://github.com/n8n-io/n8n/issues/11441)) ([7a2be77](https://github.com/n8n-io/n8n/commit/7a2be77f384a32ede3acad8fe24fb89227c058bf))
* **Intercom Node:** Update credential to new style ([#11485](https://github.com/n8n-io/n8n/issues/11485)) ([b137e13](https://github.com/n8n-io/n8n/commit/b137e13845f0714ebf7421c837f5ab104b66709b))
# [1.66.0](https://github.com/n8n-io/n8n/compare/n8n@1.65.0...n8n@1.66.0) (2024-10-31)
### Bug Fixes
* **Asana Node:** Fix issue with pagination ([#11415](https://github.com/n8n-io/n8n/issues/11415)) ([04c075a](https://github.com/n8n-io/n8n/commit/04c075a46bcc7b1964397f0244b0fde99476212d))
* **core:** Add 'user_id' to `license-community-plus-registered` telemetry event ([#11430](https://github.com/n8n-io/n8n/issues/11430)) ([7a8dafe](https://github.com/n8n-io/n8n/commit/7a8dafe9902fbc0d5001c50579c34959b95211ab))
* **core:** Add safeguard for command publishing ([#11337](https://github.com/n8n-io/n8n/issues/11337)) ([656439e](https://github.com/n8n-io/n8n/commit/656439e87138f9f96dea5a683cfdac3f661ffefb))
* **core:** Ensure `LoggerProxy` is not scoped ([#11379](https://github.com/n8n-io/n8n/issues/11379)) ([f4ea943](https://github.com/n8n-io/n8n/commit/f4ea943c9cb2321e41705de6c5c27535a0f5eae0))
* **core:** Ensure `remove-triggers-and-pollers` command is not debounced ([#11486](https://github.com/n8n-io/n8n/issues/11486)) ([529d4fc](https://github.com/n8n-io/n8n/commit/529d4fc3ef5206bd1b02d27634342cc50b45997e))
* **core:** Ensure job processor does not reprocess amended executions ([#11438](https://github.com/n8n-io/n8n/issues/11438)) ([c152a3a](https://github.com/n8n-io/n8n/commit/c152a3ac56f140a39eea4771a94f5a3082118df7))
* **core:** Fix Message Event Bus Metrics not counting up for labeled metrics ([#11396](https://github.com/n8n-io/n8n/issues/11396)) ([7fc3b25](https://github.com/n8n-io/n8n/commit/7fc3b25d21c6c4f1802f34b1ae065a65cac3001b))
* **core:** Fix resolving of $fromAI expression via `evaluateExpression` ([#11397](https://github.com/n8n-io/n8n/issues/11397)) ([2e64464](https://github.com/n8n-io/n8n/commit/2e6446454defbd3e5a47b66e6fd46d4f1b9fbd0f))
* **core:** Make execution and its data creation atomic ([#11392](https://github.com/n8n-io/n8n/issues/11392)) ([ed30d43](https://github.com/n8n-io/n8n/commit/ed30d43236bf3c6b657022636a02a41be01aa152))
* **core:** On unhandled rejections, extract the original exception correctly ([#11389](https://github.com/n8n-io/n8n/issues/11389)) ([8608bae](https://github.com/n8n-io/n8n/commit/8608baeb7ec302daddc8adca6e39778dcf7b2eda))
* **editor:** Fix TypeError: Cannot read properties of undefined (reading '0') ([#11399](https://github.com/n8n-io/n8n/issues/11399)) ([ae37c52](https://github.com/n8n-io/n8n/commit/ae37c520a91c75e353e818944b36a3619c0d8b4a))
* **editor:** Add Retry button for AI Assistant errors ([#11345](https://github.com/n8n-io/n8n/issues/11345)) ([7699240](https://github.com/n8n-io/n8n/commit/7699240073122cdef31cf109fd37fa66961f588a))
* **editor:** Change tooltip for workflow with execute workflow trigger ([#11374](https://github.com/n8n-io/n8n/issues/11374)) ([dcd6038](https://github.com/n8n-io/n8n/commit/dcd6038c3085135803cdaa546a239359a6d449eb))
* **editor:** Ensure toasts show above modal overlays ([#11410](https://github.com/n8n-io/n8n/issues/11410)) ([351134f](https://github.com/n8n-io/n8n/commit/351134f786af933f5f310bf8d9897269387635a0))
* **editor:** Fit view consistently after nodes are initialized on new canvas ([#11457](https://github.com/n8n-io/n8n/issues/11457)) ([497d637](https://github.com/n8n-io/n8n/commit/497d637fc5308b9c4a06bc764152fde1f1a9c130))
* **editor:** Fix adding connections when initializing workspace in templates view on new canvas ([#11451](https://github.com/n8n-io/n8n/issues/11451)) ([ea47b02](https://github.com/n8n-io/n8n/commit/ea47b025fb16c967d4fc73dcacc6e260d2aecd61))
* **editor:** Fix rendering of AI logs ([#11450](https://github.com/n8n-io/n8n/issues/11450)) ([73b0a80](https://github.com/n8n-io/n8n/commit/73b0a80ac92b4f4b5a300d0ec1c833b4395a222a))
* **editor:** Hide data mapping tooltip in credential edit modal ([#11356](https://github.com/n8n-io/n8n/issues/11356)) ([ff14dcb](https://github.com/n8n-io/n8n/commit/ff14dcb3a1ddaea4eca7c1ecd2e92c0abb0c413c))
* **editor:** Prevent running workflow that has issues if listening to webhook ([#11402](https://github.com/n8n-io/n8n/issues/11402)) ([8b0a48f](https://github.com/n8n-io/n8n/commit/8b0a48f53010378e497e4cc362fda75a958cf363))
* **editor:** Run external hooks after settings have been initialized ([#11423](https://github.com/n8n-io/n8n/issues/11423)) ([0ab24c8](https://github.com/n8n-io/n8n/commit/0ab24c814abd1787268750ba808993ab2735ac52))
* **editor:** Support middle click to scroll when using a mouse on new canvas ([#11384](https://github.com/n8n-io/n8n/issues/11384)) ([46f3b4a](https://github.com/n8n-io/n8n/commit/46f3b4a258f89f02e0d2bd1eef25a22e3a721167))
* **HTTP Request Tool Node:** Fix HTML response optimization issue ([#11439](https://github.com/n8n-io/n8n/issues/11439)) ([cf37e94](https://github.com/n8n-io/n8n/commit/cf37e94dd875e9f6ab1f189146fb34e7296af93c))
* **n8n Form Node:** Form Trigger does not wait in multi-form mode ([#11404](https://github.com/n8n-io/n8n/issues/11404)) ([151f4dd](https://github.com/n8n-io/n8n/commit/151f4dd7b8f800af424f8ae64cb8238975fb3cb8))
* Update required node js version in CONTRIBUTING.md ([#11437](https://github.com/n8n-io/n8n/issues/11437)) ([4f511aa](https://github.com/n8n-io/n8n/commit/4f511aab68651caa8fe47f70cd7cdb88bb06a3e2))
### Features
* **Anthropic Chat Model Node:** Add model claude-3-5-sonnet-20241022 ([#11465](https://github.com/n8n-io/n8n/issues/11465)) ([f6c8890](https://github.com/n8n-io/n8n/commit/f6c8890a8069de221b9b96e735418ecc9624cf7b))
* **core:** Handle nodes with multiple inputs and connections during partial executions ([#11376](https://github.com/n8n-io/n8n/issues/11376)) ([cb7c4d2](https://github.com/n8n-io/n8n/commit/cb7c4d29a6f042b590822e5b9c67fff0a8f0863d))
* **editor:** Add descriptive header to projects /workflow ([#11203](https://github.com/n8n-io/n8n/issues/11203)) ([5d19e8f](https://github.com/n8n-io/n8n/commit/5d19e8f2b45dc1abc5a8253f9e3a0fdacb1ebd91))
* **editor:** Improve placeholder for vector store tool ([#11483](https://github.com/n8n-io/n8n/issues/11483)) ([629e092](https://github.com/n8n-io/n8n/commit/629e09240785bc648ff6575f97910fbb4e77cdab))
* **editor:** Remove edge execution animation on new canvas ([#11446](https://github.com/n8n-io/n8n/issues/11446)) ([a701d87](https://github.com/n8n-io/n8n/commit/a701d87f5ba94ffc811e424b60e188b26ac6c1c5))
* **editor:** Update ownership pills ([#11155](https://github.com/n8n-io/n8n/issues/11155)) ([8147038](https://github.com/n8n-io/n8n/commit/8147038cf87dca657602e617e49698065bf1a63f))
# [1.65.0](https://github.com/n8n-io/n8n/compare/n8n@1.64.0...n8n@1.65.0) (2024-10-24)
### Bug Fixes
* **AI Agent Node:** Preserve `intermediateSteps` when using output parser with non-tool agent ([#11363](https://github.com/n8n-io/n8n/issues/11363)) ([e61a853](https://github.com/n8n-io/n8n/commit/e61a8535aa39653b9a87575ea911a65318282167))
* **API:** `PUT /credentials/:id` should move the specified credential, not the first one in the database ([#11365](https://github.com/n8n-io/n8n/issues/11365)) ([e6b2f8e](https://github.com/n8n-io/n8n/commit/e6b2f8e7e6ebbb6e3776a976297d519e99ac6c64))
* **API:** Correct credential schema for response in `POST /credentials` ([#11340](https://github.com/n8n-io/n8n/issues/11340)) ([f495875](https://github.com/n8n-io/n8n/commit/f4958756b4976e0b608b9155dab84564f7e8804e))
* **core:** Account for waiting jobs during shutdown ([#11338](https://github.com/n8n-io/n8n/issues/11338)) ([c863abd](https://github.com/n8n-io/n8n/commit/c863abd08300b53ea898fc4d06aae97dec7afa9b))
* **core:** Add missing primary key to execution annotation tags table ([#11168](https://github.com/n8n-io/n8n/issues/11168)) ([b4b543d](https://github.com/n8n-io/n8n/commit/b4b543d41daa07753eca24ab93bf7445f672361d))
* **core:** Change dedupe value column type from varchar(255) to text ([#11357](https://github.com/n8n-io/n8n/issues/11357)) ([7a71cff](https://github.com/n8n-io/n8n/commit/7a71cff4d75fe4e7282a398b4843428e0161ba8c))
* **core:** Do not debounce webhooks, triggers and pollers activation ([#11306](https://github.com/n8n-io/n8n/issues/11306)) ([64bddf8](https://github.com/n8n-io/n8n/commit/64bddf86536ddd688638a643d24f80c947a12f31))
* **core:** Enforce nodejs version consistently ([#11323](https://github.com/n8n-io/n8n/issues/11323)) ([0fa2e8c](https://github.com/n8n-io/n8n/commit/0fa2e8ca85005362d9043d82469f3c3525f4c4ef))
* **core:** Fix memory issue with empty model response ([#11300](https://github.com/n8n-io/n8n/issues/11300)) ([216b119](https://github.com/n8n-io/n8n/commit/216b119350949de70f15cf2d61f474770803ad7a))
* **core:** Fix race condition when resolving post-execute promise ([#11360](https://github.com/n8n-io/n8n/issues/11360)) ([4f1816e](https://github.com/n8n-io/n8n/commit/4f1816e03db00219bc2e723e3048848aef7f8fe1))
* **core:** Sanitise IdP provided information in SAML test pages ([#11171](https://github.com/n8n-io/n8n/issues/11171)) ([74fc388](https://github.com/n8n-io/n8n/commit/74fc3889b946e8f224e65ef8d3d44125404aa4fc))
* Don't show pin button in input panel when there's binary data ([#11267](https://github.com/n8n-io/n8n/issues/11267)) ([c0b5b92](https://github.com/n8n-io/n8n/commit/c0b5b92f62a2d7ba60492eb27daced268b654fe9))
* **editor:** Add Personal project to main navigation ([#11161](https://github.com/n8n-io/n8n/issues/11161)) ([1f441f9](https://github.com/n8n-io/n8n/commit/1f441f97528f58e905eaf8930577bbcd08debf06))
* **editor:** Fix Cannot read properties of undefined (reading 'finished') ([#11367](https://github.com/n8n-io/n8n/issues/11367)) ([475d72e](https://github.com/n8n-io/n8n/commit/475d72e0bc9e13c6dc56129902f6f89c67547f78))
* **editor:** Fix delete all existing executions ([#11352](https://github.com/n8n-io/n8n/issues/11352)) ([3ec103f](https://github.com/n8n-io/n8n/commit/3ec103f8baaa89e579844947d945f00bec9e498e))
* **editor:** Fix pin data button disappearing after reload ([#11198](https://github.com/n8n-io/n8n/issues/11198)) ([3b2f63e](https://github.com/n8n-io/n8n/commit/3b2f63e248cd0cba04087e2f40e13d670073707d))
* **editor:** Fix RunData non-binary pagination when binary data is present ([#11309](https://github.com/n8n-io/n8n/issues/11309)) ([901888d](https://github.com/n8n-io/n8n/commit/901888d5b1027098653540c72f787f176941f35a))
* **editor:** Fix sorting problem in older browsers that don't support `toSorted` ([#11204](https://github.com/n8n-io/n8n/issues/11204)) ([c728a2f](https://github.com/n8n-io/n8n/commit/c728a2ffe01f510a237979a54897c4680a407800))
* **editor:** Follow-up fixes to projects side menu ([#11327](https://github.com/n8n-io/n8n/issues/11327)) ([4dde772](https://github.com/n8n-io/n8n/commit/4dde772814c55e66efcc9b369ae443328af21b14))
* **editor:** Keep always focus on the first item on the node's search panel ([#11193](https://github.com/n8n-io/n8n/issues/11193)) ([c57cac9](https://github.com/n8n-io/n8n/commit/c57cac9e4d447c3a4240a565f9f2de8aa3b7c513))
* **editor:** Open Community+ enrollment modal only for the instance owner ([#11292](https://github.com/n8n-io/n8n/issues/11292)) ([76724c3](https://github.com/n8n-io/n8n/commit/76724c3be6e001792433045c2b2aac0ef16d4b8a))
* **editor:** Record sessionStarted telemetry event in Setting Store ([#11334](https://github.com/n8n-io/n8n/issues/11334)) ([1b734dd](https://github.com/n8n-io/n8n/commit/1b734dd9f42885594ce02400cfb395a4f5e7e088))
* Ensure NDV params don't get cut off early and scrolled to the top ([#11252](https://github.com/n8n-io/n8n/issues/11252)) ([054fe97](https://github.com/n8n-io/n8n/commit/054fe9745ff6864f9088aa4cd66ed9e7869520d5))
* **HTTP Request Tool Node:** Fix the undefined response issue when authentication is enabled ([#11343](https://github.com/n8n-io/n8n/issues/11343)) ([094ec68](https://github.com/n8n-io/n8n/commit/094ec68d4c00848013aa4eec4ac5efbd2c92afc5))
* Include error in the message in JS task runner sandbox ([#11359](https://github.com/n8n-io/n8n/issues/11359)) ([0708b3a](https://github.com/n8n-io/n8n/commit/0708b3a1f8097af829c92fe106ea6ba375d6c500))
* **Microsoft SQL Node:** Fix execute query to allow for non select query to run ([#11335](https://github.com/n8n-io/n8n/issues/11335)) ([ba158b4](https://github.com/n8n-io/n8n/commit/ba158b4f8533bd3430db8766d4921f75db5c1a11))
* **OpenAI Chat Model Node, Ollama Chat Model Node:** Change default model to a more up-to-date option ([#11293](https://github.com/n8n-io/n8n/issues/11293)) ([0be04c6](https://github.com/n8n-io/n8n/commit/0be04c6348d8c059a96c3d37a6d6cd587bfb97f3))
* **Pinecone Vector Store Node:** Prevent populating of vectors after manually stopping the execution ([#11288](https://github.com/n8n-io/n8n/issues/11288)) ([fbae17d](https://github.com/n8n-io/n8n/commit/fbae17d8fb35a5197fa183e3639bb36762dc73d2))
* **Postgres Node:** Special datetime values cause errors ([#11225](https://github.com/n8n-io/n8n/issues/11225)) ([3c57f46](https://github.com/n8n-io/n8n/commit/3c57f46aaeb968d2974f2dc9790317a6a6fab624))
* Resend invite operation on users list ([#11351](https://github.com/n8n-io/n8n/issues/11351)) ([e4218de](https://github.com/n8n-io/n8n/commit/e4218debd18812fa3aa508339afd3de03c4d69dc))
* **SSH Node:** Cleanup temporary binary files as soon as possible ([#11305](https://github.com/n8n-io/n8n/issues/11305)) ([08a7b5b](https://github.com/n8n-io/n8n/commit/08a7b5b7425663ec6593114921c2e22ab37d039e))
### Features
* Add report bug buttons ([#11304](https://github.com/n8n-io/n8n/issues/11304)) ([296f68f](https://github.com/n8n-io/n8n/commit/296f68f041b93fd32ac7be2b53c2b41d58c2998a))
* **AI Agent Node:** Make tools optional when using OpenAI model with Tools agent ([#11212](https://github.com/n8n-io/n8n/issues/11212)) ([fed7c3e](https://github.com/n8n-io/n8n/commit/fed7c3ec1fb0553adaa9a933f91aabfd54fe83a3))
* **core:** introduce JWT API keys for the public API ([#11005](https://github.com/n8n-io/n8n/issues/11005)) ([679fa4a](https://github.com/n8n-io/n8n/commit/679fa4a10a85fc96e12ca66fe12cdb32368bc12b))
* **core:** Enforce config file permissions on startup ([#11328](https://github.com/n8n-io/n8n/issues/11328)) ([c078a51](https://github.com/n8n-io/n8n/commit/c078a516bec857831cc904ef807d0791b889f3a2))
* **core:** Handle cycles in workflows when partially executing them ([#11187](https://github.com/n8n-io/n8n/issues/11187)) ([321d6de](https://github.com/n8n-io/n8n/commit/321d6deef18806d88d97afef2f2c6f29e739ccb4))
* **editor:** Separate node output execution tooltip from status icon ([#11196](https://github.com/n8n-io/n8n/issues/11196)) ([cd15e95](https://github.com/n8n-io/n8n/commit/cd15e959c7af82a7d8c682e94add2b2640624a70))
* **GitHub Node:** Add workflow resource operations ([#10744](https://github.com/n8n-io/n8n/issues/10744)) ([d309112](https://github.com/n8n-io/n8n/commit/d3091126472faa2c8f270650e54027d19dc56bb6))
* **n8n Form Page Node:** New node ([#10390](https://github.com/n8n-io/n8n/issues/10390)) ([643d66c](https://github.com/n8n-io/n8n/commit/643d66c0ae084a0d93dac652703adc0a32cab8de))
* **n8n Google My Business Node:** New node ([#10504](https://github.com/n8n-io/n8n/issues/10504)) ([bf28fbe](https://github.com/n8n-io/n8n/commit/bf28fbefe5e8ba648cba1555a2d396b75ee32bbb))
* Run `mfa.beforeSetup` hook before enabling MFA ([#11116](https://github.com/n8n-io/n8n/issues/11116)) ([25c1c32](https://github.com/n8n-io/n8n/commit/25c1c3218cf1075ca3abd961236f3b2fbd9d6ba9))
* **Structured Output Parser Node:** Refactor Output Parsers and Improve Error Handling ([#11148](https://github.com/n8n-io/n8n/issues/11148)) ([45274f2](https://github.com/n8n-io/n8n/commit/45274f2e7f081e194e330e1c9e6a5c26fca0b141))
# [1.64.0](https://github.com/n8n-io/n8n/compare/n8n@1.63.0...n8n@1.64.0) (2024-10-16)
### Bug Fixes
* Adjust arrow button colors in dark mode ([#11248](https://github.com/n8n-io/n8n/issues/11248)) ([439132c](https://github.com/n8n-io/n8n/commit/439132c291a812d57702c94eaa12878394ac4c69))
* **core:** Ensure error reporter does not promote `info` to `error` messages ([#11245](https://github.com/n8n-io/n8n/issues/11245)) ([a7fc7fc](https://github.com/n8n-io/n8n/commit/a7fc7fc22997acec86dc94386c95349fd018f4ae))
* **core:** Override executions mode if `regular` during worker startup ([#11250](https://github.com/n8n-io/n8n/issues/11250)) ([c0aa28c](https://github.com/n8n-io/n8n/commit/c0aa28c6cf3f77b04e04663217c9df8e3803ed3f))
* **core:** Wrap nodes for tool use at a suitable time ([#11238](https://github.com/n8n-io/n8n/issues/11238)) ([c2fb881](https://github.com/n8n-io/n8n/commit/c2fb881d61291209802438d95892d052f5c82d43))
* Don't show pinned data tooltip for pinned nodes ([#11249](https://github.com/n8n-io/n8n/issues/11249)) ([c2ad156](https://github.com/n8n-io/n8n/commit/c2ad15646d326a8f71e314d54efe202a5bcdd296))
* **editor:** Bring back the "Forgot password" link on SigninView ([#11216](https://github.com/n8n-io/n8n/issues/11216)) ([4e78c46](https://github.com/n8n-io/n8n/commit/4e78c46a7450c7fc0694369944d4fb446cef2348))
* **editor:** Fix chat crashing when rendering output-parsed content ([#11210](https://github.com/n8n-io/n8n/issues/11210)) ([4aaebfd](https://github.com/n8n-io/n8n/commit/4aaebfd4358f590e98c453ad4e65cc2c9d0f76f8))
* **editor:** Make submit in ChangePasswordView work again ([#11227](https://github.com/n8n-io/n8n/issues/11227)) ([4f27b39](https://github.com/n8n-io/n8n/commit/4f27b39b45b58779d363980241e6e5e11b58f5da))
* Expressions display actual result of evaluating expression inside string ([#11257](https://github.com/n8n-io/n8n/issues/11257)) ([7f5f0a9](https://github.com/n8n-io/n8n/commit/7f5f0a9df3b3fae6e2f9787443ac1cf9415d5932))
* **Google Ads Node:** Update to use v17 api ([#11243](https://github.com/n8n-io/n8n/issues/11243)) ([3d97f02](https://github.com/n8n-io/n8n/commit/3d97f02a8d2b6e5bc7c97c5271bed97417ecacd2))
* **Google Calendar Node:** Fix issue with conference data types not loading ([#11185](https://github.com/n8n-io/n8n/issues/11185)) ([4012758](https://github.com/n8n-io/n8n/commit/401275884e5db0287e4eeffb3c7497dd5e024880))
* **Google Calendar Node:** Mode to add or replace attendees in event update ([#11132](https://github.com/n8n-io/n8n/issues/11132)) ([6c6a8ef](https://github.com/n8n-io/n8n/commit/6c6a8efdea83cf7194304ce089d7b72d8f6c1a9d))
* **HTTP Request Tool Node:** Respond with an error when receive binary response ([#11219](https://github.com/n8n-io/n8n/issues/11219)) ([0d23a7f](https://github.com/n8n-io/n8n/commit/0d23a7fb5ba41545f70c4848d30b90af91b1e7e6))
* **MySQL Node:** Fix "Maximum call stack size exceeded" error when handling a large number of rows ([#11242](https://github.com/n8n-io/n8n/issues/11242)) ([b7ee0c4](https://github.com/n8n-io/n8n/commit/b7ee0c4087eae346bc7e5360130d6c812dbe99db))
* **n8n Trigger Node:** Merge with Workflow Trigger node ([#11174](https://github.com/n8n-io/n8n/issues/11174)) ([6ec6b51](https://github.com/n8n-io/n8n/commit/6ec6b5197ae97eb86496effd458fcc0b9b223ef3))
* **OpenAI Node:** Fix tool parameter parsing issue ([#11201](https://github.com/n8n-io/n8n/issues/11201)) ([5a1d81a](https://github.com/n8n-io/n8n/commit/5a1d81ad917fde5cd6a387fe2d4ec6aab6b71349))
* **Set Node:** Fix issue with UI properties not being hidden ([#11263](https://github.com/n8n-io/n8n/issues/11263)) ([1affc27](https://github.com/n8n-io/n8n/commit/1affc27b6bf9a559061a06f92bebe8167d938665))
* **Strava Trigger Node:** Fix issue with webhook not being deleted ([#11226](https://github.com/n8n-io/n8n/issues/11226)) ([566529c](https://github.com/n8n-io/n8n/commit/566529ca1149988a54a58b3c34bbe4d9f1add6db))
### Features
* Add tracking for node errors and update node graph ([#11060](https://github.com/n8n-io/n8n/issues/11060)) ([d3b05f1](https://github.com/n8n-io/n8n/commit/d3b05f1c54e62440666297d8e484ccd22168da48))
* **core:** Dedupe ([#10101](https://github.com/n8n-io/n8n/issues/10101)) ([52dd2c7](https://github.com/n8n-io/n8n/commit/52dd2c76196c6895b47145c2b85a6895ce2874d4))
* **editor:** Send workflow context to assistant store ([#11135](https://github.com/n8n-io/n8n/issues/11135)) ([fade9e4](https://github.com/n8n-io/n8n/commit/fade9e43c84a0ae1fbc80f3ee546a418970e2380))
* **Gong Node:** New node ([#10777](https://github.com/n8n-io/n8n/issues/10777)) ([785b47f](https://github.com/n8n-io/n8n/commit/785b47feb3b83cf36aaed57123f8baca2bbab307))
### Performance Improvements
* **Google Sheets Node:** Don't load whole spreadsheet dataset to determine columns when appending data ([#11235](https://github.com/n8n-io/n8n/issues/11235)) ([26ad091](https://github.com/n8n-io/n8n/commit/26ad091f473bca4e5d3bdc257e0818be02e52db5))
# [1.63.0](https://github.com/n8n-io/n8n/compare/n8n@1.62.1...n8n@1.63.0) (2024-10-09)

View file

@ -68,7 +68,7 @@ If you already have VS Code and Docker installed, you can click [here](https://v
#### Node.js
[Node.js](https://nodejs.org/en/) version 18.10 or newer is required for development purposes.
[Node.js](https://nodejs.org/en/) version 20.15 or newer is required for development purposes.
#### pnpm

View file

@ -40,6 +40,14 @@ export function getOutputPanelDataContainer() {
return getOutputPanel().getByTestId('ndv-data-container');
}
export function getOutputTableRows() {
return getOutputPanelDataContainer().find('table tr');
}
export function getOutputTableRow(row: number) {
return getOutputTableRows().eq(row);
}
export function getOutputPanelTable() {
return getOutputPanelDataContainer().get('table');
}

View file

@ -69,6 +69,13 @@ export function getNodeCreatorPlusButton() {
return cy.getByTestId('node-creator-plus-button');
}
export function getCanvasNodes() {
return cy.ifCanvasVersion(
() => cy.getByTestId('canvas-node'),
() => cy.getByTestId('canvas-node').not('[data-node-type="n8n-nodes-internal.addNodes"]'),
);
}
/**
* Actions
*/

View file

@ -20,6 +20,7 @@ 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);
@ -114,6 +115,7 @@ 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(CODE_NODE_NAME);
@ -146,18 +148,14 @@ describe('Undo/Redo', () => {
it('should undo/redo deleting a connection using context menu', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.nodeConnections().realHover();
cy.get('.connection-actions .delete')
.filter(':visible')
.should('be.visible')
.click({ force: true });
WorkflowPage.actions.deleteNodeBetweenNodes(SCHEDULE_TRIGGER_NODE_NAME, CODE_NODE_NAME);
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);
});
// 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);
@ -206,6 +204,7 @@ 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);
@ -244,6 +243,7 @@ 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);

View file

@ -16,6 +16,7 @@ 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');
@ -25,10 +26,11 @@ describe('Canvas Actions', () => {
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 });
cy.get('.jtk-connector').should('have.length', 1);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
WorkflowPage.getters.nodeViewBackground().click(600, 400, { force: true });
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
@ -40,16 +42,16 @@ describe('Canvas Actions', () => {
);
WorkflowPage.getters
.canvasNodeInputEndpointByName(`${EDIT_FIELDS_SET_NODE_NAME}1`)
.should('have.class', 'jtk-endpoint-connected');
.getConnectionBetweenNodes(MANUAL_TRIGGER_NODE_DISPLAY_NAME, `${EDIT_FIELDS_SET_NODE_NAME}1`)
.should('be.visible');
cy.get('.jtk-connector').should('have.length', 1);
WorkflowPage.getters.nodeConnections().should('have.length', 1);
// Disconnect Set1
cy.drag(
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
[-200, 100],
);
cy.get('.jtk-connector').should('have.length', 0);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
});
it('should add first step', () => {
@ -74,7 +76,7 @@ describe('Canvas Actions', () => {
it('should add a connected node using plus endpoint', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
cy.get('.plus-endpoint').should('be.visible').click();
WorkflowPage.getters.canvasNodePlusEndpointByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME);
WorkflowPage.getters.nodeCreatorSearchBar().type('{enter}');
@ -85,7 +87,7 @@ describe('Canvas Actions', () => {
it('should add a connected node dragging from node creator', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
cy.get('.plus-endpoint').should('be.visible').click();
WorkflowPage.getters.canvasNodePlusEndpointByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME);
cy.drag(WorkflowPage.getters.nodeCreatorNodeItems().first(), [100, 100], {
@ -99,7 +101,7 @@ describe('Canvas Actions', () => {
it('should open a category when trying to drag and drop it on the canvas', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
cy.get('.plus-endpoint').should('be.visible').click();
WorkflowPage.getters.canvasNodePlusEndpointByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME);
cy.drag(WorkflowPage.getters.nodeCreatorActionItems().first(), [100, 100], {
@ -114,7 +116,7 @@ describe('Canvas Actions', () => {
it('should add disconnected node if nothing is selected', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
// Deselect nodes
WorkflowPage.getters.nodeViewBackground().click({ force: true });
WorkflowPage.getters.nodeView().click({ force: true });
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.canvasNodes().should('have.length', 2);
WorkflowPage.getters.nodeConnections().should('have.length', 0);
@ -136,10 +138,10 @@ describe('Canvas Actions', () => {
WorkflowPage.getters.nodeConnections().should('have.length', 3);
WorkflowPage.getters.canvasNodeByName(EDIT_FIELDS_SET_NODE_NAME).then(($editFieldsNode) => {
const editFieldsNodeLeft = parseFloat($editFieldsNode.css('left'));
const editFieldsNodeLeft = WorkflowPage.getters.getNodeLeftPosition($editFieldsNode);
WorkflowPage.getters.canvasNodeByName(HTTP_REQUEST_NODE_NAME).then(($httpNode) => {
const httpNodeLeft = parseFloat($httpNode.css('left'));
const httpNodeLeft = WorkflowPage.getters.getNodeLeftPosition($httpNode);
expect(httpNodeLeft).to.be.lessThan(editFieldsNodeLeft);
});
});
@ -159,10 +161,12 @@ describe('Canvas Actions', () => {
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.getters.nodeConnections().first().realHover();
cy.get('.connection-actions .delete').first().click({ force: true });
WorkflowPage.actions.deleteNodeBetweenNodes(MANUAL_TRIGGER_NODE_DISPLAY_NAME, CODE_NODE_NAME);
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();
@ -216,10 +220,10 @@ describe('Canvas Actions', () => {
WorkflowPage.actions.hitSelectAll();
WorkflowPage.actions.hitCopy();
successToast().should('contain', 'Copied!');
successToast().should('contain', 'Copied to clipboard');
WorkflowPage.actions.copyNode(CODE_NODE_NAME);
successToast().should('contain', 'Copied!');
successToast().should('contain', 'Copied to clipboard');
});
it('should select/deselect all nodes', () => {
@ -231,17 +235,31 @@ 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();
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
cy.wait(500);
cy.get('body').type('{leftArrow}');
WorkflowPage.getters.canvasNodes().first().should('have.class', 'jtk-drag-selected');
const selectedCanvasNodes = () =>
cy.ifCanvasVersion(
() => WorkflowPage.getters.canvasNodes(),
() => WorkflowPage.getters.canvasNodes().parent(),
);
cy.ifCanvasVersion(
() => selectedCanvasNodes().first().should('have.class', 'jtk-drag-selected'),
() => selectedCanvasNodes().first().should('have.class', 'selected'),
);
cy.get('body').type('{rightArrow}');
WorkflowPage.getters.canvasNodes().last().should('have.class', 'jtk-drag-selected');
cy.ifCanvasVersion(
() => selectedCanvasNodes().last().should('have.class', 'jtk-drag-selected'),
() => selectedCanvasNodes().last().should('have.class', 'selected'),
);
});
// 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();
@ -251,6 +269,7 @@ describe('Canvas Actions', () => {
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
@ -262,6 +281,7 @@ describe('Canvas Actions', () => {
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]);

View file

@ -9,6 +9,7 @@ import {
} from './../constants';
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();
@ -52,15 +53,15 @@ describe('Canvas Node Manipulation and Navigation', () => {
cy.reload();
cy.waitForLoad();
// Make sure outputless switch was connected correctly
cy.get(
`[data-target-node="${SWITCH_NODE_NAME}1"][data-source-node="${EDIT_FIELDS_SET_NODE_NAME}3"]`,
).should('be.visible');
WorkflowPage.getters
.getConnectionBetweenNodes(`${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
.canvasNodeInputEndpointByName(setName)
.should('have.class', 'jtk-endpoint-connected');
.getConnectionBetweenNodes(`${SWITCH_NODE_NAME}`, setName)
.should('exist');
}
});
@ -69,9 +70,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
for (let i = 0; i < 2; i++) {
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true);
WorkflowPage.getters
.nodeViewBackground()
.click((i + 1) * 200, (i + 1) * 200, { force: true });
WorkflowPage.getters.nodeView().click((i + 1) * 200, (i + 1) * 200, { force: true });
}
WorkflowPage.actions.zoomToFit();
@ -84,8 +83,6 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
);
cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 2);
// Connect Set1 and Set2 to merge
cy.draganddrop(
WorkflowPage.getters.getEndpointSelector('plus', EDIT_FIELDS_SET_NODE_NAME),
@ -95,20 +92,36 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.getters.getEndpointSelector('plus', `${EDIT_FIELDS_SET_NODE_NAME}1`),
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 1),
);
cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 4);
const checkConnections = () => {
WorkflowPage.getters
.getConnectionBetweenNodes(
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
`${EDIT_FIELDS_SET_NODE_NAME}1`,
)
.should('exist');
WorkflowPage.getters
.getConnectionBetweenNodes(EDIT_FIELDS_SET_NODE_NAME, MERGE_NODE_NAME)
.should('exist');
WorkflowPage.getters
.getConnectionBetweenNodes(`${EDIT_FIELDS_SET_NODE_NAME}1`, MERGE_NODE_NAME)
.should('exist');
};
checkConnections();
// Make sure all connections are there after save & reload
WorkflowPage.actions.saveWorkflowOnButtonClick();
cy.reload();
cy.waitForLoad();
cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 4);
checkConnections();
// cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 4);
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.get('[data-label="2 items"]').should('be.visible');
cy.ifCanvasVersion(
() => cy.get('[data-label="2 items"]').should('be.visible'),
() => cy.getByTestId('canvas-node-output-handle').contains('2 items').should('be.visible'),
);
});
it('should add nodes and check execution success', () => {
@ -120,16 +133,42 @@ describe('Canvas Node Manipulation and Navigation', () => {
WorkflowPage.actions.zoomToFit();
WorkflowPage.actions.executeWorkflow();
cy.get('.jtk-connector.success').should('have.length', 3);
cy.get('.data-count').should('have.length', 4);
cy.get('.plus-draggable-endpoint').should('have.class', 'ep-success');
cy.ifCanvasVersion(
() => cy.get('.jtk-connector.success').should('have.length', 3),
() => cy.get('[data-edge-status=success]').should('have.length', 3),
);
cy.ifCanvasVersion(
() => cy.get('.data-count').should('have.length', 4),
() => cy.getByTestId('canvas-node-status-success').should('have.length', 4),
);
cy.ifCanvasVersion(
() => cy.get('.plus-draggable-endpoint').should('have.class', 'ep-success'),
() => cy.getByTestId('canvas-handle-plus').should('have.attr', 'data-plus-type', 'success'),
);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.zoomToFit();
cy.get('.plus-draggable-endpoint').filter(':visible').should('not.have.class', 'ep-success');
cy.get('.jtk-connector.success').should('have.length', 3);
cy.get('.jtk-connector').should('have.length', 4);
cy.ifCanvasVersion(
() =>
cy
.get('.plus-draggable-endpoint')
.filter(':visible')
.should('not.have.class', 'ep-success'),
() =>
cy.getByTestId('canvas-handle-plus').should('not.have.attr', 'data-plus-type', 'success'),
);
cy.ifCanvasVersion(
() => cy.get('.jtk-connector.success').should('have.length', 3),
// The new version of the canvas correctly shows executed data being passed to the input of the next node
() => cy.get('[data-edge-status=success]').should('have.length', 4),
);
cy.ifCanvasVersion(
() => cy.get('.data-count').should('have.length', 4),
() => cy.getByTestId('canvas-node-status-success').should('have.length', 4),
);
});
it('should delete node using context menu', () => {
@ -194,19 +233,29 @@ describe('Canvas Node Manipulation and Navigation', () => {
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()
.last()
.then(($node) => {
const { left, top } = $node.position();
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], {
clickToFinish: true,
});
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,
});
}
WorkflowPage.getters
.canvasNodes()
.last()
@ -218,91 +267,80 @@ describe('Canvas Node Manipulation and Navigation', () => {
});
});
it('should zoom in', () => {
WorkflowPage.getters.zoomInButton().should('be.visible').click();
WorkflowPage.getters
.nodeView()
.should(
'have.css',
'transform',
`matrix(${ZOOM_IN_X1_FACTOR}, 0, 0, ${ZOOM_IN_X1_FACTOR}, 0, 0)`,
describe('Canvas Zoom Functionality', () => {
const getContainer = () =>
cy.ifCanvasVersion(
() => WorkflowPage.getters.nodeView(),
() => WorkflowPage.getters.canvasViewport(),
);
WorkflowPage.getters.zoomInButton().click();
WorkflowPage.getters
.nodeView()
.should(
'have.css',
'transform',
`matrix(${ZOOM_IN_X2_FACTOR}, 0, 0, ${ZOOM_IN_X2_FACTOR}, 0, 0)`,
);
});
const checkZoomLevel = (expectedFactor: number) => {
return getContainer().should(($nodeView) => {
const newTransform = $nodeView.css('transform');
const newScale = parseFloat(newTransform.split(',')[0].slice(7));
it('should zoom out', () => {
WorkflowPage.getters.zoomOutButton().should('be.visible').click();
WorkflowPage.getters
.nodeView()
.should(
'have.css',
'transform',
`matrix(${ZOOM_OUT_X1_FACTOR}, 0, 0, ${ZOOM_OUT_X1_FACTOR}, 0, 0)`,
);
WorkflowPage.getters.zoomOutButton().click();
WorkflowPage.getters
.nodeView()
.should(
'have.css',
'transform',
`matrix(${ZOOM_OUT_X2_FACTOR}, 0, 0, ${ZOOM_OUT_X2_FACTOR}, 0, 0)`,
);
});
expect(newScale).to.be.closeTo(expectedFactor, 0.2);
});
};
it('should zoom using scroll or pinch gesture', () => {
WorkflowPage.actions.pinchToZoom(1, 'zoomIn');
WorkflowPage.getters
.nodeView()
.should(
'have.css',
'transform',
`matrix(${PINCH_ZOOM_IN_FACTOR}, 0, 0, ${PINCH_ZOOM_IN_FACTOR}, 0, 0)`,
const zoomAndCheck = (action: 'zoomIn' | 'zoomOut', expectedFactor: number) => {
WorkflowPage.getters[`${action}Button`]().click();
checkZoomLevel(expectedFactor);
};
it('should zoom in', () => {
WorkflowPage.getters.zoomInButton().should('be.visible');
getContainer().then(($nodeView) => {
const initialTransform = $nodeView.css('transform');
const initialScale =
initialTransform === 'none' ? 1 : parseFloat(initialTransform.split(',')[0].slice(7));
zoomAndCheck('zoomIn', initialScale * ZOOM_IN_X1_FACTOR);
zoomAndCheck('zoomIn', initialScale * ZOOM_IN_X2_FACTOR);
});
});
it('should zoom out', () => {
zoomAndCheck('zoomOut', ZOOM_OUT_X1_FACTOR);
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');
// Zoom in 1x + Zoom out 1x should reset to default (=1)
WorkflowPage.getters.nodeView().should('have.css', 'transform', 'matrix(1, 0, 0, 1, 0, 0)');
WorkflowPage.actions.pinchToZoom(1, 'zoomOut');
checkZoomLevel(1); // Zoom in 1x + Zoom out 1x should reset to default (=1)
WorkflowPage.actions.pinchToZoom(1, 'zoomOut');
WorkflowPage.getters
.nodeView()
.should(
'have.css',
'transform',
`matrix(${PINCH_ZOOM_OUT_FACTOR}, 0, 0, ${PINCH_ZOOM_OUT_FACTOR}, 0, 0)`,
WorkflowPage.actions.pinchToZoom(1, 'zoomOut');
cy.ifCanvasVersion(
() => checkZoomLevel(PINCH_ZOOM_OUT_FACTOR),
() => checkZoomLevel(ZOOM_OUT_X1_FACTOR),
);
});
});
it('should reset zoom', () => {
// Reset zoom should not appear until zoom level changed
WorkflowPage.getters.resetZoomButton().should('not.exist');
WorkflowPage.getters.zoomInButton().click();
WorkflowPage.getters.resetZoomButton().should('be.visible').click();
WorkflowPage.getters
.nodeView()
.should(
'have.css',
'transform',
`matrix(${DEFAULT_ZOOM_FACTOR}, 0, 0, ${DEFAULT_ZOOM_FACTOR}, 0, 0)`,
);
});
it('should reset zoom', () => {
WorkflowPage.getters.resetZoomButton().should('not.exist');
WorkflowPage.getters.zoomInButton().click();
WorkflowPage.getters.resetZoomButton().should('be.visible').click();
checkZoomLevel(DEFAULT_ZOOM_FACTOR);
});
it('should zoom to fit', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
// At this point last added node should be off-screen
WorkflowPage.getters.canvasNodes().last().should('not.be.visible');
WorkflowPage.getters.zoomToFitButton().click();
WorkflowPage.getters.canvasNodes().last().should('be.visible');
it('should zoom to fit', () => {
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
// At this point last added node should be off-screen
WorkflowPage.getters.canvasNodes().last().should('not.be.visible');
WorkflowPage.getters.zoomToFitButton().click();
WorkflowPage.getters.canvasNodes().last().should('be.visible');
});
});
it('should disable node (context menu or shortcut)', () => {
@ -426,9 +464,9 @@ describe('Canvas Node Manipulation and Navigation', () => {
cy.reload();
cy.waitForLoad();
WorkflowPage.getters.canvasNodes().should('have.length', 2);
cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 1);
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));
@ -441,6 +479,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
});
});
// 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');

View file

@ -1,3 +1,6 @@
import { nanoid } from 'nanoid';
import { simpleWebhookCall, waitForWebhook } from './16-webhook-node.cy';
import {
HTTP_REQUEST_NODE_NAME,
MANUAL_TRIGGER_NODE_NAME,
@ -7,6 +10,7 @@ import {
} from '../constants';
import { WorkflowPage, NDV } from '../pages';
import { errorToast } from '../pages/notifications';
import { getVisiblePopper } from '../utils';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
@ -212,6 +216,42 @@ 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();
// hide other visible popper on workflow execute button
workflowPage.getters.canvasNodes().eq(0).click();
getVisiblePopper().should('have.length', 0);
});
});
function setExpressionOnStringValueInSet(expression: string) {

View file

@ -16,12 +16,14 @@ describe('n8n Form Trigger', () => {
ndv.getters.parameterInput('formDescription').type('Test Form Description');
ndv.getters.parameterInput('fieldLabel').type('Test Field 1');
ndv.getters.backToCanvas().click();
workflowPage.getters.nodeIssuesByName('n8n Form Trigger').should('not.exist');
workflowPage.getters.nodeIssuesByName('On form submission').should('not.exist');
});
it('should fill up form fields', () => {
workflowPage.actions.addInitialNodeToCanvas('n8n Form Trigger');
workflowPage.getters.canvasNodes().first().dblclick();
workflowPage.actions.addInitialNodeToCanvas('n8n Form Trigger', {
isTrigger: true,
action: 'On new n8n Form event',
});
ndv.getters.parameterInput('formTitle').type('Test Form');
ndv.getters.parameterInput('formDescription').type('Test Form Description');
//fill up first field of type number
@ -96,6 +98,6 @@ describe('n8n Form Trigger', () => {
.type('Your test form was successfully submitted');
ndv.getters.backToCanvas().click();
workflowPage.getters.nodeIssuesByName('n8n Form Trigger').should('not.exist');
workflowPage.getters.nodeIssuesByName('On form submission').should('not.exist');
});
});

View file

@ -9,7 +9,7 @@ const workflowPage = new WorkflowPage();
const ndv = new NDV();
const credentialsModal = new CredentialsModal();
const waitForWebhook = 500;
export const waitForWebhook = 500;
interface SimpleWebhookCallOptions {
method: string;
@ -21,7 +21,7 @@ interface SimpleWebhookCallOptions {
authentication?: string;
}
const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
export const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
const {
authentication,
method,
@ -65,15 +65,23 @@ const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
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);
cy.request(method, `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then((response) => {
callEndpoint((response) => {
expect(response.status).to.eq(200);
ndv.getters.outputPanel().contains('headers');
});
}
return {
callEndpoint,
};
};
describe('Webhook Trigger node', () => {

View file

@ -226,6 +226,7 @@ describe('Sharing', { disableAutoLogin: true }, () => {
.should('have.length', 1)
.click();
credentialsModal.getters.saveButton().click();
credentialsModal.getters.saveButton().should('have.text', 'Saved');
credentialsModal.actions.close();
projects.getProjectTabWorkflows().click();
@ -252,12 +253,13 @@ describe('Sharing', { disableAutoLogin: true }, () => {
credentialsModal.getters.usersSelect().click();
getVisibleSelect().find('li').should('have.length', 4).first().click();
credentialsModal.getters.saveButton().click();
credentialsModal.getters.saveButton().should('have.text', 'Saved');
credentialsModal.actions.close();
credentialsPage.getters
.credentialCards()
.should('have.length', 2)
.filter(':contains("Owned by me")')
.filter(':contains("Personal")')
.should('have.length', 1);
});
});

View file

@ -1,6 +1,7 @@
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';
import { isCanvasV2 } from '../utils/workflowUtils';
const workflowPage = new WorkflowPageClass();
const executionsTab = new WorkflowExecutionsTab();
@ -117,15 +118,22 @@ describe('Execution', () => {
.canvasNodeByName('Manual')
.within(() => cy.get('.fa-check'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-sync-alt').should('not.visible'));
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');
clearNotifications();
// Clear execution data
workflowPage.getters.clearExecutionDataButton().should('be.visible');
@ -206,6 +214,7 @@ 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');
@ -267,9 +276,17 @@ describe('Execution', () => {
.canvasNodeByName('Webhook')
.within(() => cy.get('.fa-check'))
.should('exist');
workflowPage.getters
.canvasNodeByName('Wait')
.within(() => cy.get('.fa-sync-alt').should('not.visible'));
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'));
@ -295,6 +312,7 @@ describe('Execution', () => {
});
});
// FIXME: Canvas V2: Missing pinned states for `edge-label-wrapper`
describe('connections should be colored differently for pinned data', () => {
beforeEach(() => {
cy.createFixtureWorkflow('Schedule_pinned.json');

View file

@ -15,7 +15,7 @@ import {
TRELLO_NODE_NAME,
} from '../constants';
import { CredentialsModal, CredentialsPage, NDV, WorkflowPage } from '../pages';
import { successToast } from '../pages/notifications';
import { errorToast, successToast } from '../pages/notifications';
import { getVisibleSelect } from '../utils';
const credentialsPage = new CredentialsPage();
@ -26,6 +26,22 @@ const nodeDetailsView = new NDV();
const NEW_CREDENTIAL_NAME = 'Something else';
const NEW_CREDENTIAL_NAME2 = 'Something else entirely';
function createNotionCredential() {
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME);
workflowPage.actions.openNode(NOTION_NODE_NAME);
workflowPage.getters.nodeCredentialsSelect().click();
getVisibleSelect().find('li').last().click();
credentialsModal.actions.fillCredentialsForm();
cy.get('body').type('{esc}');
workflowPage.actions.deleteNode(NOTION_NODE_NAME);
}
function deleteSelectedCredential() {
workflowPage.getters.nodeCredentialsEditButton().click();
credentialsModal.getters.deleteButton().click();
cy.get('.el-message-box').find('button').contains('Yes').click();
}
describe('Credentials', () => {
beforeEach(() => {
cy.visit(credentialsPage.url);
@ -229,6 +245,40 @@ describe('Credentials', () => {
.should('have.value', NEW_CREDENTIAL_NAME);
});
it('should set a default credential when adding nodes', () => {
workflowPage.actions.visit();
createNotionCredential();
workflowPage.actions.addNodeToCanvas(NOTION_NODE_NAME, true, true);
workflowPage.getters
.nodeCredentialsSelect()
.find('input')
.should('have.value', NEW_NOTION_ACCOUNT_NAME);
deleteSelectedCredential();
});
it('should set a default credential when editing a node', () => {
workflowPage.actions.visit();
createNotionCredential();
workflowPage.actions.addNodeToCanvas(HTTP_REQUEST_NODE_NAME, true, true);
nodeDetailsView.getters.parameterInput('authentication').click();
getVisibleSelect().find('li').contains('Predefined').click();
nodeDetailsView.getters.parameterInput('nodeCredentialType').click();
getVisibleSelect().find('li').contains('Notion API').click();
workflowPage.getters
.nodeCredentialsSelect()
.find('input')
.should('have.value', NEW_NOTION_ACCOUNT_NAME);
deleteSelectedCredential();
});
it('should setup generic authentication for HTTP node', () => {
workflowPage.actions.visit();
workflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
@ -278,4 +328,25 @@ describe('Credentials', () => {
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
nodeDetailsView.getters.copyInput().should('not.exist');
});
it('ADO-2583 should show notifications above credential modal overlay', () => {
// check error notifications because they are sticky
cy.intercept('POST', '/rest/credentials', { forceNetworkError: true });
credentialsPage.getters.createCredentialButton().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');
credentialsModal.getters.saveButton().click({ force: true });
errorToast().should('have.length', 1);
errorToast().should('be.visible');
errorToast().should('have.css', 'z-index', '2100');
cy.get('.el-overlay').should('have.css', 'z-index', '2001');
});
});

View file

@ -0,0 +1,21 @@
import { WEBHOOK_NODE_NAME } from '../constants';
import { NDV, WorkflowPage } from '../pages';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
describe('ADO-2270 Save button resets on webhook node open', () => {
it('should not reset the save button if webhook node is opened and closed', () => {
workflowPage.actions.visit();
workflowPage.actions.addInitialNodeToCanvas(WEBHOOK_NODE_NAME);
workflowPage.getters.saveButton().click();
workflowPage.actions.openNode(WEBHOOK_NODE_NAME);
ndv.actions.close();
cy.ifCanvasVersion(
() => cy.getByTestId('workflow-save-button').should('not.contain', 'Saved'),
() => cy.getByTestId('workflow-save-button').should('contain', 'Saved'),
);
});
});

View file

@ -90,6 +90,14 @@ function createRunDataWithError(inputMessage: string) {
routine: 'InitPostgres',
} as unknown as Error,
} as ExecutionError,
metadata: {
subRun: [
{
node: 'Postgres Chat Memory',
runIndex: 0,
},
],
},
}),
createMockNodeExecutionData(AGENT_NODE_NAME, {
executionStatus: 'error',
@ -124,14 +132,6 @@ function createRunDataWithError(inputMessage: string) {
description: 'Internal error',
message: 'Internal error',
} as unknown as ExecutionError,
metadata: {
subRun: [
{
node: 'Postgres Chat Memory',
runIndex: 0,
},
],
},
}),
];
}

View file

@ -0,0 +1,86 @@
import { NDV, WorkflowPage } from '../pages';
const workflowPage = new WorkflowPage();
const ndv = new NDV();
describe('ADO-2362 ADO-2350 NDV Prevent clipping long parameters and scrolling to expression', () => {
it('should show last parameters and open at scroll top of parameters', () => {
workflowPage.actions.visit();
cy.createFixtureWorkflow('Test-workflow-with-long-parameters.json');
workflowPage.actions.openNode('Schedule Trigger');
ndv.getters.inlineExpressionEditorInput().should('be.visible');
ndv.actions.close();
workflowPage.actions.openNode('Edit Fields1');
// first parameter should be visible
ndv.getters.inputLabel().eq(0).should('include.text', 'Mode');
ndv.getters.inputLabel().eq(0).should('be.visible');
ndv.getters.inlineExpressionEditorInput().should('have.length', 2);
// last parameter in view should be visible
ndv.getters.inlineExpressionEditorInput().eq(0).should('have.text', 'should be visible!');
ndv.getters.inlineExpressionEditorInput().eq(0).should('be.visible');
// next parameter in view should not be visible
ndv.getters.inlineExpressionEditorInput().eq(1).should('have.text', 'not visible');
ndv.getters.inlineExpressionEditorInput().eq(1).should('not.be.visible');
ndv.actions.close();
workflowPage.actions.openNode('Schedule Trigger');
// first parameter (notice) should be visible
ndv.getters.nthParam(0).should('include.text', 'This workflow will run on the schedule ');
ndv.getters.inputLabel().eq(0).should('be.visible');
ndv.getters.inlineExpressionEditorInput().should('have.length', 2);
// last parameter in view should be visible
ndv.getters.inlineExpressionEditorInput().eq(0).should('have.text', 'should be visible');
ndv.getters.inlineExpressionEditorInput().eq(0).should('be.visible');
// next parameter in view should not be visible
ndv.getters.inlineExpressionEditorInput().eq(1).should('have.text', 'not visible');
ndv.getters.inlineExpressionEditorInput().eq(1).should('not.be.visible');
ndv.actions.close();
workflowPage.actions.openNode('Slack');
// first field (credentials) should be visible
ndv.getters.nodeCredentialsLabel().should('be.visible');
// last parameter in view should be visible
ndv.getters.inlineExpressionEditorInput().eq(0).should('have.text', 'should be visible');
ndv.getters.inlineExpressionEditorInput().eq(0).should('be.visible');
// next parameter in view should not be visible
ndv.getters.inlineExpressionEditorInput().eq(1).should('have.text', 'not visible');
ndv.getters.inlineExpressionEditorInput().eq(1).should('not.be.visible');
});
it('NODE-1272 ensure expressions scrolled to top, not middle', () => {
workflowPage.actions.visit();
cy.createFixtureWorkflow('Test-workflow-with-long-parameters.json');
workflowPage.actions.openNode('With long expression');
ndv.getters.inlineExpressionEditorInput().eq(0).should('be.visible');
// should be scrolled at top
ndv.getters
.inlineExpressionEditorInput()
.eq(0)
.find('.cm-line')
.eq(0)
.should('have.text', '1 visible!');
ndv.getters.inlineExpressionEditorInput().eq(0).find('.cm-line').eq(0).should('be.visible');
ndv.getters
.inlineExpressionEditorInput()
.eq(0)
.find('.cm-line')
.eq(6)
.should('have.text', '7 not visible!');
ndv.getters.inlineExpressionEditorInput().eq(0).find('.cm-line').eq(6).should('not.be.visible');
});
});

View file

@ -117,7 +117,8 @@ describe('Debug', () => {
workflowPage.getters.canvasNodes().last().find('.node-info-icon').should('be.empty');
workflowPage.getters.canvasNodes().first().dblclick();
ndv.getters.pinDataButton().click();
ndv.actions.unPinData();
ndv.actions.close();
workflowPage.actions.saveWorkflowUsingKeyboardShortcut();

View file

@ -44,6 +44,7 @@ import {
openNode,
getConnectionBySourceAndTarget,
} from '../composables/workflow';
import { NDV, WorkflowPage } from '../pages';
import { createMockNodeExecutionData, runMockWorkflowExecution } from '../utils';
describe('Langchain Integration', () => {
@ -232,95 +233,96 @@ describe('Langchain Integration', () => {
const inputMessage = 'Hello!';
const outputMessage = 'Hi there! How can I assist you today?';
const runData = [
createMockNodeExecutionData(MANUAL_CHAT_TRIGGER_NODE_NAME, {
jsonData: {
main: { input: inputMessage },
},
}),
createMockNodeExecutionData(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, {
jsonData: {
ai_languageModel: {
response: {
generations: [
{
text: `{
"action": "Final Answer",
"action_input": "${outputMessage}"
}`,
message: {
lc: 1,
type: 'constructor',
id: ['langchain', 'schema', 'AIMessage'],
kwargs: {
content: `{
"action": "Final Answer",
"action_input": "${outputMessage}"
}`,
additional_kwargs: {},
},
},
generationInfo: { finish_reason: 'stop' },
},
],
llmOutput: {
tokenUsage: {
completionTokens: 26,
promptTokens: 519,
totalTokens: 545,
},
},
},
},
},
metadata: {
subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }],
},
inputOverride: {
ai_languageModel: [
[
{
json: {
messages: [
{
lc: 1,
type: 'constructor',
id: ['langchain', 'schema', 'SystemMessage'],
kwargs: {
content:
'Assistant is a large language model trained by OpenAI.\n\nAssistant is designed to be able to assist with a wide range of tasks, from answering simple questions to providing in-depth explanations and discussions on a wide range of topics. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand.\n\nAssistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide accurate and informative responses to a wide range of questions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in discussions and provide explanations and descriptions on a wide range of topics.\n\nOverall, Assistant is a powerful system that can help with a wide range of tasks and provide valuable insights and information on a wide range of topics. Whether you need help with a specific question or just want to have a conversation about a particular topic, Assistant is here to assist. However, above all else, all responses must adhere to the format of RESPONSE FORMAT INSTRUCTIONS.',
additional_kwargs: {},
},
},
{
lc: 1,
type: 'constructor',
id: ['langchain', 'schema', 'HumanMessage'],
kwargs: {
content:
'TOOLS\n------\nAssistant can ask the user to use tools to look up information that may be helpful in answering the users original question. The tools the human can use are:\n\n\n\nRESPONSE FORMAT INSTRUCTIONS\n----------------------------\n\nOutput a JSON markdown code snippet containing a valid JSON object in one of two formats:\n\n**Option 1:**\nUse this if you want the human to use a tool.\nMarkdown code snippet formatted in the following schema:\n\n```json\n{\n "action": string, // The action to take. Must be one of []\n "action_input": string // The input to the action. May be a stringified object.\n}\n```\n\n**Option #2:**\nUse this if you want to respond directly and conversationally to the human. Markdown code snippet formatted in the following schema:\n\n```json\n{\n "action": "Final Answer",\n "action_input": string // You should put what you want to return to use here and make sure to use valid json newline characters.\n}\n```\n\nFor both options, remember to always include the surrounding markdown code snippet delimiters (begin with "```json" and end with "```")!\n\n\nUSER\'S INPUT\n--------------------\nHere is the user\'s input (remember to respond with a markdown code snippet of a json blob with a single action, and NOTHING else):\n\nHello!',
additional_kwargs: {},
},
},
],
options: { stop: ['Observation:'], promptIndex: 0 },
},
},
],
],
},
}),
createMockNodeExecutionData(AGENT_NODE_NAME, {
jsonData: {
main: { output: 'Hi there! How can I assist you today?' },
},
}),
];
runMockWorkflowExecution({
trigger: () => {
sendManualChatMessage(inputMessage);
},
runData: [
createMockNodeExecutionData(MANUAL_CHAT_TRIGGER_NODE_NAME, {
jsonData: {
main: { input: inputMessage },
},
}),
createMockNodeExecutionData(AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, {
jsonData: {
ai_languageModel: {
response: {
generations: [
{
text: `{
"action": "Final Answer",
"action_input": "${outputMessage}"
}`,
message: {
lc: 1,
type: 'constructor',
id: ['langchain', 'schema', 'AIMessage'],
kwargs: {
content: `{
"action": "Final Answer",
"action_input": "${outputMessage}"
}`,
additional_kwargs: {},
},
},
generationInfo: { finish_reason: 'stop' },
},
],
llmOutput: {
tokenUsage: {
completionTokens: 26,
promptTokens: 519,
totalTokens: 545,
},
},
},
},
},
inputOverride: {
ai_languageModel: [
[
{
json: {
messages: [
{
lc: 1,
type: 'constructor',
id: ['langchain', 'schema', 'SystemMessage'],
kwargs: {
content:
'Assistant is a large language model trained by OpenAI.\n\nAssistant is designed to be able to assist with a wide range of tasks, from answering simple questions to providing in-depth explanations and discussions on a wide range of topics. As a language model, Assistant is able to generate human-like text based on the input it receives, allowing it to engage in natural-sounding conversations and provide responses that are coherent and relevant to the topic at hand.\n\nAssistant is constantly learning and improving, and its capabilities are constantly evolving. It is able to process and understand large amounts of text, and can use this knowledge to provide accurate and informative responses to a wide range of questions. Additionally, Assistant is able to generate its own text based on the input it receives, allowing it to engage in discussions and provide explanations and descriptions on a wide range of topics.\n\nOverall, Assistant is a powerful system that can help with a wide range of tasks and provide valuable insights and information on a wide range of topics. Whether you need help with a specific question or just want to have a conversation about a particular topic, Assistant is here to assist. However, above all else, all responses must adhere to the format of RESPONSE FORMAT INSTRUCTIONS.',
additional_kwargs: {},
},
},
{
lc: 1,
type: 'constructor',
id: ['langchain', 'schema', 'HumanMessage'],
kwargs: {
content:
'TOOLS\n------\nAssistant can ask the user to use tools to look up information that may be helpful in answering the users original question. The tools the human can use are:\n\n\n\nRESPONSE FORMAT INSTRUCTIONS\n----------------------------\n\nOutput a JSON markdown code snippet containing a valid JSON object in one of two formats:\n\n**Option 1:**\nUse this if you want the human to use a tool.\nMarkdown code snippet formatted in the following schema:\n\n```json\n{\n "action": string, // The action to take. Must be one of []\n "action_input": string // The input to the action. May be a stringified object.\n}\n```\n\n**Option #2:**\nUse this if you want to respond directly and conversationally to the human. Markdown code snippet formatted in the following schema:\n\n```json\n{\n "action": "Final Answer",\n "action_input": string // You should put what you want to return to use here and make sure to use valid json newline characters.\n}\n```\n\nFor both options, remember to always include the surrounding markdown code snippet delimiters (begin with "```json" and end with "```")!\n\n\nUSER\'S INPUT\n--------------------\nHere is the user\'s input (remember to respond with a markdown code snippet of a json blob with a single action, and NOTHING else):\n\nHello!',
additional_kwargs: {},
},
},
],
options: { stop: ['Observation:'], promptIndex: 0 },
},
},
],
],
},
}),
createMockNodeExecutionData(AGENT_NODE_NAME, {
jsonData: {
main: { output: 'Hi there! How can I assist you today?' },
},
metadata: {
subRun: [{ node: AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME, runIndex: 0 }],
},
}),
],
runData,
lastNodeExecuted: AGENT_NODE_NAME,
});
@ -357,4 +359,56 @@ 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');
});
});

View file

@ -1,21 +1,29 @@
import workflow from '../fixtures/Manual_wait_set.json';
import { getOutputTableRow } from '../composables/ndv';
import { getCanvasNodes, openNode } from '../composables/workflow';
import SIMPLE_WORKFLOW from '../fixtures/Manual_wait_set.json';
import WORKFLOW_WITH_PINNED from '../fixtures/Webhook_set_pinned.json';
import { importWorkflow, visitDemoPage } from '../pages/demo';
import { errorToast } from '../pages/notifications';
import { WorkflowPage } from '../pages/workflow';
const workflowPage = new WorkflowPage();
describe('Demo', () => {
beforeEach(() => {
cy.overrideSettings({ previewMode: true });
cy.signout();
});
it('can import template', () => {
visitDemoPage();
errorToast().should('not.exist');
importWorkflow(workflow);
workflowPage.getters.canvasNodes().should('have.length', 3);
importWorkflow(SIMPLE_WORKFLOW);
getCanvasNodes().should('have.length', 3);
});
it('can import workflow with pin data', () => {
visitDemoPage();
importWorkflow(WORKFLOW_WITH_PINNED);
getCanvasNodes().should('have.length', 2);
openNode('Webhook');
getOutputTableRow(0).should('include.text', 'headers');
getOutputTableRow(1).should('include.text', 'dragons');
});
it('can override theme to dark', () => {

View file

@ -15,6 +15,7 @@ import {
NDV,
MainSidebar,
} from '../pages';
import { clearNotifications } from '../pages/notifications';
import { getVisibleDropdown, getVisibleModalOverlay, getVisibleSelect } from '../utils';
const workflowsPage = new WorkflowsPage();
@ -440,7 +441,9 @@ describe('Projects', { disableAutoLogin: true }, () => {
.should('contain.text', 'Notion account personal project');
});
it('should move resources between projects', () => {
// Skip flaky test
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
it.skip('should move resources between projects', () => {
cy.signinAsOwner();
cy.visit(workflowsPage.url);
@ -448,38 +451,48 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowsPage.getters.workflowCards().should('not.have.length');
workflowsPage.getters.newWorkflowButtonCard().click();
projects.createWorkflow('Test_workflow_1.json', 'Workflow in Home project');
clearNotifications();
projects.getHomeButton().click();
projects.getProjectTabCredentials().should('be.visible').click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
projects.createCredential('Credential in Home project');
clearNotifications();
// Create a project and add a credential and a workflow to it
projects.createProject('Project 1');
clearNotifications();
projects.getProjectTabCredentials().click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
projects.createCredential('Credential in Project 1');
clearNotifications();
projects.getProjectTabWorkflows().click();
workflowsPage.getters.newWorkflowButtonCard().click();
projects.createWorkflow('Test_workflow_1.json', 'Workflow in Project 1');
clearNotifications();
// Create another project and add a credential and a workflow to it
projects.createProject('Project 2');
clearNotifications();
projects.getProjectTabCredentials().click();
credentialsPage.getters.emptyListCreateCredentialButton().click();
projects.createCredential('Credential in Project 2');
clearNotifications();
projects.getProjectTabWorkflows().click();
workflowsPage.getters.newWorkflowButtonCard().click();
projects.createWorkflow('Test_workflow_1.json', 'Workflow in Project 2');
clearNotifications();
// Move the workflow owned by me from Home to Project 1
// Move the workflow Personal from Home to Project 1
projects.getHomeButton().click();
workflowsPage.getters
.workflowCards()
.should('have.length', 3)
.filter(':contains("Owned by me")')
.filter(':contains("Personal")')
.should('exist');
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
workflowsPage.getters.workflowMoveButton().click();
@ -496,11 +509,12 @@ describe('Projects', { disableAutoLogin: true }, () => {
.filter(':contains("Project 1")')
.click();
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
clearNotifications();
workflowsPage.getters
.workflowCards()
.should('have.length', 3)
.filter(':contains("Owned by me")')
.filter(':contains("Personal")')
.should('not.exist');
// Move the workflow from Project 1 to Project 2
@ -527,6 +541,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowsPage.getters.workflowCards().should('have.length', 2);
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
workflowsPage.getters.workflowMoveButton().click();
clearNotifications();
projects
.getResourceMoveModal()
@ -566,10 +581,11 @@ describe('Projects', { disableAutoLogin: true }, () => {
.click();
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
clearNotifications();
workflowsPage.getters
.workflowCards()
.should('have.length', 3)
.filter(':contains("Owned by me")')
.filter(':contains("Personal")')
.should('have.length', 1);
// Move the credential from Project 1 to Project 2
@ -591,7 +607,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
.filter(':contains("Project 2")')
.click();
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
clearNotifications();
credentialsPage.getters.credentialCards().should('not.have.length');
// Move the credential from Project 2 to admin user
@ -637,10 +653,12 @@ describe('Projects', { disableAutoLogin: true }, () => {
.click();
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
clearNotifications();
credentialsPage.getters
.credentialCards()
.should('have.length', 3)
.filter(':contains("Owned by me")')
.filter(':contains("Personal")')
.should('have.length', 2);
// Move the credential from admin user back to its original project (Project 1)
@ -668,7 +686,9 @@ describe('Projects', { disableAutoLogin: true }, () => {
.should('have.length', 1);
});
it('should allow to change inaccessible credential when the workflow was moved to a team project', () => {
// Skip flaky test
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
it.skip('should allow to change inaccessible credential when the workflow was moved to a team project', () => {
cy.signinAsOwner();
cy.visit(workflowsPage.url);
@ -699,7 +719,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowsPage.getters
.workflowCards()
.should('have.length', 1)
.filter(':contains("Owned by me")')
.filter(':contains("Personal")')
.should('exist');
workflowsPage.getters.workflowCardActions('My workflow').click();
workflowsPage.getters.workflowMoveButton().click();
@ -720,7 +740,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
workflowsPage.getters
.workflowCards()
.should('have.length', 1)
.filter(':contains("Owned by me")')
.filter(':contains("Personal")')
.should('not.exist');
//Log out with instance owner and log in with the member user

View file

@ -23,6 +23,7 @@ describe('Manual partial execution', () => {
canvas.actions.openNode('Webhook1');
ndv.getters.nodeRunSuccessIndicator().should('exist');
ndv.getters.nodeRunTooltipIndicator().should('exist');
ndv.getters.outputRunSelector().should('not.exist'); // single run
});
});

View file

@ -4,6 +4,7 @@ import { clickCreateNewCredential, openCredentialSelect } from '../composables/n
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();
@ -11,6 +12,7 @@ const ndv = new NDV();
const aiAssistant = new AIAssistant();
const credentialsPage = new CredentialsPage();
const credentialsModal = new CredentialsModal();
const nodeCreatorFeature = new NodeCreator();
describe('AI Assistant::disabled', () => {
beforeEach(() => {
@ -145,42 +147,6 @@ describe('AI Assistant::enabled', () => {
aiAssistant.getters.chatMessagesUser().eq(0).should('contain.text', "Sure, let's do it");
});
it('should show quick replies when node is executed after new suggestion', () => {
cy.intercept('POST', '/rest/ai/chat', (req) => {
req.reply((res) => {
if (['init-error-helper', 'message'].includes(req.body.payload.type)) {
res.send({
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
});
} else if (req.body.payload.type === 'event') {
res.send({
statusCode: 200,
fixture: 'aiAssistant/responses/node_execution_error_response.json',
});
} else {
res.send({ statusCode: 500 });
}
});
}).as('chatRequest');
cy.createFixtureWorkflow('aiAssistant/workflows/test_workflow.json');
wf.actions.openNode('Edit Fields');
ndv.getters.nodeExecuteButton().click();
aiAssistant.getters.nodeErrorViewAssistantButton().click();
cy.wait('@chatRequest');
aiAssistant.getters.chatMessagesAssistant().should('have.length', 1);
ndv.getters.nodeExecuteButton().click();
cy.wait('@chatRequest');
// Respond 'Yes' to the quick reply (request new suggestion)
aiAssistant.getters.quickReplies().contains('Yes').click();
cy.wait('@chatRequest');
// No quick replies at this point
aiAssistant.getters.quickReplies().should('not.exist');
ndv.getters.nodeExecuteButton().click();
// But after executing the node again, quick replies should be shown
aiAssistant.getters.quickReplies().should('have.length', 2);
});
it('should warn before starting a new session', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
@ -316,6 +282,20 @@ describe('AI Assistant::enabled', () => {
wf.getters.isWorkflowSaved();
aiAssistant.getters.placeholderMessage().should('not.exist');
});
it('should send message via enter even with global NodeCreator panel opened', () => {
cy.intercept('POST', '/rest/ai/chat', {
statusCode: 200,
fixture: 'aiAssistant/responses/simple_message_response.json',
}).as('chatRequest');
wf.actions.addInitialNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
aiAssistant.actions.openChat();
nodeCreatorFeature.actions.openNodeCreator();
aiAssistant.getters.chatInput().type('Hello{Enter}');
aiAssistant.getters.placeholderMessage().should('not.exist');
});
});
describe('AI Assistant Credential Help', () => {

View file

@ -133,9 +133,10 @@ describe('NDV', () => {
"An expression here won't work because it uses .item and n8n can't figure out the matching item.",
);
ndv.getters.nodeRunErrorIndicator().should('be.visible');
ndv.getters.nodeRunTooltipIndicator().should('be.visible');
// The error details should be hidden behind a tooltip
ndv.getters.nodeRunErrorIndicator().should('not.contain', 'Start Time');
ndv.getters.nodeRunErrorIndicator().should('not.contain', 'Execution Time');
ndv.getters.nodeRunTooltipIndicator().should('not.contain', 'Start Time');
ndv.getters.nodeRunTooltipIndicator().should('not.contain', 'Execution Time');
});
it('should save workflow using keyboard shortcut from NDV', () => {
@ -617,8 +618,10 @@ describe('NDV', () => {
// Should not show run info before execution
ndv.getters.nodeRunSuccessIndicator().should('not.exist');
ndv.getters.nodeRunErrorIndicator().should('not.exist');
ndv.getters.nodeRunTooltipIndicator().should('not.exist');
ndv.getters.nodeExecuteButton().click();
ndv.getters.nodeRunSuccessIndicator().should('exist');
ndv.getters.nodeRunTooltipIndicator().should('exist');
});
it('should properly show node execution indicator for multiple nodes', () => {
@ -630,6 +633,7 @@ describe('NDV', () => {
// Manual tigger node should show success indicator
workflowPage.actions.openNode('When clicking Test workflow');
ndv.getters.nodeRunSuccessIndicator().should('exist');
ndv.getters.nodeRunTooltipIndicator().should('exist');
// Code node should show error
ndv.getters.backToCanvas().click();
workflowPage.actions.openNode('Code');
@ -791,4 +795,46 @@ describe('NDV', () => {
.find('[data-test-id=run-data-schema-item]')
.should('contain.text', 'onlyOnItem3');
});
it('should keep search expanded after Test step node run', () => {
cy.createFixtureWorkflow('Test_ndv_search.json');
workflowPage.actions.zoomToFit();
workflowPage.actions.executeWorkflow();
workflowPage.actions.openNode('Edit Fields');
ndv.getters.outputPanel().should('be.visible');
ndv.getters.outputPanel().findChildByTestId('ndv-search').click().type('US');
ndv.getters.outputTableRow(1).find('mark').should('have.text', 'US');
ndv.actions.execute();
ndv.getters
.outputPanel()
.findChildByTestId('ndv-search')
.should('be.visible')
.should('have.value', 'US');
});
it('should not show items count when seaching in schema view', () => {
cy.createFixtureWorkflow('Test_ndv_search.json');
workflowPage.actions.zoomToFit();
workflowPage.actions.openNode('Edit Fields');
ndv.getters.outputPanel().should('be.visible');
ndv.actions.execute();
ndv.actions.switchOutputMode('Schema');
ndv.getters.outputPanel().find('[data-test-id=ndv-search]').click().type('US');
ndv.getters.outputPanel().find('[data-test-id=ndv-items-count]').should('not.exist');
});
it('should show additional tooltip when seaching in schema view if no matches', () => {
cy.createFixtureWorkflow('Test_ndv_search.json');
workflowPage.actions.zoomToFit();
workflowPage.actions.openNode('Edit Fields');
ndv.getters.outputPanel().should('be.visible');
ndv.actions.execute();
ndv.actions.switchOutputMode('Schema');
ndv.getters.outputPanel().find('[data-test-id=ndv-search]').click().type('foo');
ndv.getters
.outputPanel()
.contains('To search field contents rather than just names, use Table or JSON view')
.should('exist');
});
});

View file

@ -162,21 +162,21 @@ return []
cy.get('#tab-code').should('have.class', 'is-active');
});
it('should show error based on status code', () => {
const prompt = nanoid(20);
cy.get('#tab-ask-ai').click();
ndv.actions.executePrevious();
const handledCodes = [
{ code: 400, message: 'Code generation failed due to an unknown reason' },
{ code: 413, message: 'Your workflow data is too large for AI to process' },
{ code: 429, message: "We've hit our rate limit with our AI partner" },
{ code: 500, message: 'Code generation failed due to an unknown reason' },
];
cy.getByTestId('ask-ai-prompt-input').type(prompt);
handledCodes.forEach(({ code, message }) => {
it(`should show error based on status code ${code}`, () => {
const prompt = nanoid(20);
cy.get('#tab-ask-ai').click();
ndv.actions.executePrevious();
const handledCodes = [
{ code: 400, message: 'Code generation failed due to an unknown reason' },
{ code: 413, message: 'Your workflow data is too large for AI to process' },
{ code: 429, message: "We've hit our rate limit with our AI partner" },
{ code: 500, message: 'Code generation failed due to an unknown reason' },
];
cy.getByTestId('ask-ai-prompt-input').type(prompt);
handledCodes.forEach(({ code, message }) => {
cy.intercept('POST', '/rest/ai/ask-ai', {
statusCode: code,
status: code,

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,39 @@
{
"nodes": [
{
"parameters": {
"path": "FwrbSiaua2Xmvn6-Z-7CQ",
"options": {}
},
"id": "8fcc7e5f-2cef-4938-9564-eea504c20aa0",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
360,
220
],
"webhookId": "9c778f2a-e882-46ed-a0e4-c8e2f76ccd65"
}
],
"connections": {},
"pinData": {
"Webhook": [
{
"headers": {
"connection": "keep-alive",
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
"accept": "*/*",
"cookie": "n8n-auth=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjNiM2FhOTE5LWRhZDgtNDE5MS1hZWZiLTlhZDIwZTZkMjJjNiIsImhhc2giOiJ1ZVAxR1F3U2paIiwiaWF0IjoxNzI4OTE1NTQyLCJleHAiOjE3Mjk1MjAzNDJ9.fV02gpUnSiUoMxHwfB0npBjcjct7Mv9vGfj-jRTT3-I",
"host": "localhost:5678",
"accept-encoding": "gzip, deflate"
},
"params": {},
"query": {},
"body": {},
"webhookUrl": "http://localhost:5678/webhook-test/FwrbSiaua2Xmvn6-Z-7CQ",
"executionMode": "test"
}
]
}
}

View file

@ -0,0 +1,150 @@
{
"meta": {
"instanceId": "777c68374367604fdf2a0bcfe9b1b574575ddea61aa8268e4bf034434bd7c894"
},
"nodes": [
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "0effebfc-fa8c-4d41-8a37-6d5695dfc9ee",
"name": "test",
"value": "test",
"type": "string"
},
{
"id": "beb8723f-6333-4186-ab88-41d4e2338866",
"name": "test",
"value": "test",
"type": "string"
},
{
"id": "85095836-4e94-442f-9270-e1a89008c129",
"name": "test",
"value": "test",
"type": "string"
},
{
"id": "b6163f8a-bca6-4364-8b38-182df37c55cd",
"name": "=should be visible!",
"value": "=not visible",
"type": "string"
}
]
},
"options": {}
},
"id": "950fcdc1-9e92-410f-8377-d4240e9bf6ff",
"name": "Edit Fields1",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
680,
460
]
},
{
"parameters": {
"messageType": "block",
"blocksUi": "blocks",
"text": "=should be visible",
"otherOptions": {
"sendAsUser": "=not visible"
}
},
"id": "dcf7410d-0f8e-4cdb-9819-ae275558bdaa",
"name": "Slack",
"type": "n8n-nodes-base.slack",
"typeVersion": 2.2,
"position": [
900,
460
],
"webhookId": "002b502e-31e5-4fdb-ac43-a56cfde8f82a"
},
{
"parameters": {
"rule": {
"interval": [
{},
{
"field": "=should be visible"
},
{
"field": "=not visible"
}
]
}
},
"id": "4c948a3f-19d4-4b08-a8be-f7d2964a21f4",
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
460,
460
]
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "5dcaab37-1146-49c6-97a3-3b2f73483270",
"name": "object",
"value": "=1 visible!\n2 {\n3 \"str\": \"two\",\n4 \"str_date\": \"{{ $now }}\",\n5 \"str_int\": \"1\",\n6 \"str_float\": \"1.234\",\n7 not visible!\n \"str_bool\": \"true\",\n \"str_email\": \"david@thedavid.com\",\n \"str_with_email\":\"My email is david@n8n.io\",\n \"str_json_single\":\"{'one':'two'}\",\n \"str_json_double\":\"{\\\"one\\\":\\\"two\\\"}\",\n \"bool\": true,\n \"list\": [1, 2, 3],\n \"decimal\": 1.234,\n \"timestamp1\": 1708695471,\n \"timestamp2\": 1708695471000,\n \"timestamp3\": 1708695471000000,\n \"num_one\": 1\n}",
"type": "object"
}
]
},
"includeOtherFields": true,
"options": {}
},
"id": "a41dfb0d-38aa-42d2-b3e2-1854090bd319",
"name": "With long expression",
"type": "n8n-nodes-base.set",
"typeVersion": 3.3,
"position": [
1100,
460
]
}
],
"connections": {
"Edit Fields1": {
"main": [
[
{
"node": "Slack",
"type": "main",
"index": 0
}
]
]
},
"Slack": {
"main": [
[
{
"node": "With long expression",
"type": "main",
"index": 0
}
]
]
},
"Schedule Trigger": {
"main": [
[
{
"node": "Edit Fields1",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {}
}

View file

@ -0,0 +1,135 @@
{
"name": "NDV search bugs (introduced by schema view?)",
"nodes": [
{
"parameters": {},
"id": "55635c7b-92ee-4d2d-a0c0-baff9ab071da",
"name": "When clicking Test workflow",
"type": "n8n-nodes-base.manualTrigger",
"position": [
800,
380
],
"typeVersion": 1
},
{
"parameters": {
"operation": "getAllPeople"
},
"id": "4737af43-e49b-4c92-b76f-32605c047114",
"name": "Customer Datastore (n8n training)",
"type": "n8n-nodes-base.n8nTrainingCustomerDatastore",
"typeVersion": 1,
"position": [
1020,
380
]
},
{
"parameters": {
"assignments": {
"assignments": []
},
"includeOtherFields": true,
"options": {}
},
"id": "8cc9b374-1856-4f3f-9315-08e6e27840d8",
"name": "Edit Fields",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1240,
380
]
}
],
"pinData": {
"Customer Datastore (n8n training)": [
{
"json": {
"id": "23423532",
"name": "Jay Gatsby",
"email": "gatsby@west-egg.com",
"notes": "Keeps asking about a green light??",
"country": "US",
"created": "1925-04-10"
}
},
{
"json": {
"id": "23423533",
"name": "José Arcadio Buendía",
"email": "jab@macondo.co",
"notes": "Lots of people named after him. Very confusing",
"country": "CO",
"created": "1967-05-05"
}
},
{
"json": {
"id": "23423534",
"name": "Max Sendak",
"email": "info@in-and-out-of-weeks.org",
"notes": "Keeps rolling his terrible eyes",
"country": "US",
"created": "1963-04-09"
}
},
{
"json": {
"id": "23423535",
"name": "Zaphod Beeblebrox",
"email": "captain@heartofgold.com",
"notes": "Felt like I was talking to more than one person",
"country": null,
"created": "1979-10-12"
}
},
{
"json": {
"id": "23423536",
"name": "Edmund Pevensie",
"email": "edmund@narnia.gov",
"notes": "Passionate sailor",
"country": "UK",
"created": "1950-10-16"
}
}
]
},
"connections": {
"When clicking Test workflow": {
"main": [
[
{
"node": "Customer Datastore (n8n training)",
"type": "main",
"index": 0
}
]
]
},
"Customer Datastore (n8n training)": {
"main": [
[
{
"node": "Edit Fields",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"settings": {
"executionOrder": "v1"
},
"versionId": "20178044-fb64-4443-88dd-e941517520d0",
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "be251a83c052a9862eeac953816fbb1464f89dfbf79d7ac490a8e336a8cc8bfd"
},
"id": "aBVnTRON9Y2cSmse",
"tags": []
}

View file

@ -20,7 +20,8 @@ export class NDV extends BasePage {
outputDataContainer: () => this.getters.outputPanel().findChildByTestId('ndv-data-container'),
outputDisplayMode: () =>
this.getters.outputPanel().findChildByTestId('ndv-run-data-display-mode').first(),
pinDataButton: () => cy.getByTestId('ndv-pin-data'),
pinDataButton: () => this.getters.outputPanel().findChildByTestId('ndv-pin-data'),
unpinDataLink: () => this.getters.outputPanel().findChildByTestId('ndv-unpin-data'),
editPinnedDataButton: () => cy.getByTestId('ndv-edit-pinned-data'),
pinnedDataEditor: () => this.getters.outputPanel().find('.cm-editor .cm-scroller .cm-content'),
runDataPaneHeader: () => cy.getByTestId('run-data-pane-header'),
@ -63,6 +64,7 @@ export class NDV extends BasePage {
nodeRenameInput: () => cy.getByTestId('node-rename-input'),
executePrevious: () => cy.getByTestId('execute-previous-node'),
httpRequestNotice: () => cy.getByTestId('node-parameters-http-notice'),
nodeCredentialsLabel: () => cy.getByTestId('credentials-label'),
nthParam: (n: number) => cy.getByTestId('node-parameters').find('.parameter-item').eq(n),
inputRunSelector: () => this.getters.inputPanel().findChildByTestId('run-selector'),
inputLinkRun: () => this.getters.inputPanel().findChildByTestId('link-run'),
@ -130,8 +132,9 @@ export class NDV extends BasePage {
codeEditorFullscreenButton: () => cy.getByTestId('code-editor-fullscreen-button'),
codeEditorDialog: () => cy.getByTestId('code-editor-fullscreen'),
codeEditorFullscreen: () => this.getters.codeEditorDialog().find('.cm-content'),
nodeRunSuccessIndicator: () => cy.getByTestId('node-run-info-success'),
nodeRunErrorIndicator: () => cy.getByTestId('node-run-info-danger'),
nodeRunTooltipIndicator: () => cy.getByTestId('node-run-info'),
nodeRunSuccessIndicator: () => cy.getByTestId('node-run-status-success'),
nodeRunErrorIndicator: () => cy.getByTestId('node-run-status-danger'),
nodeRunErrorMessage: () => cy.getByTestId('node-error-message'),
nodeRunErrorDescription: () => cy.getByTestId('node-error-description'),
fixedCollectionParameter: (paramName: string) =>
@ -146,6 +149,9 @@ export class NDV extends BasePage {
pinData: () => {
this.getters.pinDataButton().click({ force: true });
},
unPinData: () => {
this.getters.unpinDataLink().click({ force: true });
},
editPinnedData: () => {
this.getters.editPinnedDataButton().click();
},

View file

@ -2,7 +2,7 @@ import { BasePage } from './base';
import { NodeCreator } from './features/node-creator';
import { META_KEY } from '../constants';
import { getVisibleSelect } from '../utils';
import { getUniqueWorkflowName } from '../utils/workflowUtils';
import { getUniqueWorkflowName, isCanvasV2 } from '../utils/workflowUtils';
const nodeCreator = new NodeCreator();
export class WorkflowPage extends BasePage {
@ -27,7 +27,11 @@ 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.getByTestId('canvas-node'),
canvasNodes: () =>
cy.ifCanvasVersion(
() => cy.getByTestId('canvas-node'),
() => cy.getByTestId('canvas-node').not('[data-node-type="n8n-nodes-internal.addNodes"]'),
),
canvasNodeByName: (nodeName: string) =>
this.getters.canvasNodes().filter(`:contains(${nodeName})`),
nodeIssuesByName: (nodeName: string) =>
@ -37,6 +41,17 @@ export class WorkflowPage extends BasePage {
.should('have.length.greaterThan', 0)
.findChildByTestId('node-issues'),
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}"]`;
}
if (type === 'output') {
return `[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"][data-handle-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-endpoint-name='${nodeName}'][data-endpoint-type='${type}'][data-input-index='${index}']`;
},
canvasNodeInputEndpointByName: (nodeName: string, index = 0) => {
@ -46,7 +61,15 @@ export class WorkflowPage extends BasePage {
return cy.get(this.getters.getEndpointSelector('output', nodeName, index));
},
canvasNodePlusEndpointByName: (nodeName: string, index = 0) => {
return cy.get(this.getters.getEndpointSelector('plus', nodeName, index));
return cy.ifCanvasVersion(
() => cy.get(this.getters.getEndpointSelector('plus', nodeName, index)),
() =>
cy
.get(
`[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"] .clickable`,
)
.eq(index),
);
},
activatorSwitch: () => cy.getByTestId('workflow-activate-switch'),
workflowMenu: () => cy.getByTestId('workflow-menu'),
@ -56,13 +79,29 @@ export class WorkflowPage extends BasePage {
expressionModalInput: () => cy.getByTestId('expression-modal-input').find('[role=textbox]'),
expressionModalOutput: () => cy.getByTestId('expression-modal-output'),
nodeViewRoot: () => cy.getByTestId('node-view-root'),
nodeViewRoot: () =>
cy.ifCanvasVersion(
() => cy.getByTestId('node-view-root'),
() => this.getters.nodeView(),
),
copyPasteInput: () => cy.getByTestId('hidden-copy-paste'),
nodeConnections: () => cy.get('.jtk-connector'),
nodeConnections: () =>
cy.ifCanvasVersion(
() => cy.get('.jtk-connector'),
() => cy.getByTestId('edge-label-wrapper'),
),
zoomToFitButton: () => cy.getByTestId('zoom-to-fit'),
nodeEndpoints: () => cy.get('.jtk-endpoint-connected'),
disabledNodes: () => cy.get('.node-box.disabled'),
selectedNodes: () => this.getters.canvasNodes().filter('.jtk-drag-selected'),
disabledNodes: () =>
cy.ifCanvasVersion(
() => cy.get('.node-box.disabled'),
() => cy.get('[data-test-id="canvas-trigger-node"][class*="disabled"]'),
),
selectedNodes: () =>
cy.ifCanvasVersion(
() => this.getters.canvasNodes().filter('.jtk-drag-selected'),
() => this.getters.canvasNodes().parent().filter('.selected'),
),
// Workflow menu items
workflowMenuItemDuplicate: () => cy.getByTestId('workflow-menu-item-duplicate'),
workflowMenuItemDownload: () => cy.getByTestId('workflow-menu-item-download'),
@ -92,8 +131,21 @@ export class WorkflowPage extends BasePage {
shareButton: () => cy.getByTestId('workflow-share-button'),
duplicateWorkflowModal: () => cy.getByTestId('duplicate-modal'),
nodeViewBackground: () => cy.getByTestId('node-view-background'),
nodeView: () => cy.getByTestId('node-view'),
nodeViewBackground: () =>
cy.ifCanvasVersion(
() => cy.getByTestId('node-view-background'),
() => cy.getByTestId('canvas'),
),
nodeView: () =>
cy.ifCanvasVersion(
() => cy.getByTestId('node-view'),
() => cy.get('[data-test-id="canvas-wrapper"]'),
),
canvasViewport: () =>
cy.ifCanvasVersion(
() => cy.getByTestId('node-view'),
() => cy.get('.vue-flow__transformationpane.vue-flow__container'),
),
inlineExpressionEditorInput: () =>
cy.getByTestId('inline-expression-editor-input').find('[role=textbox]'),
inlineExpressionEditorOutput: () => cy.getByTestId('inline-expression-editor-output'),
@ -115,12 +167,26 @@ export class WorkflowPage extends BasePage {
ndvParameters: () => cy.getByTestId('parameter-item'),
nodeCredentialsLabel: () => cy.getByTestId('credentials-label'),
getConnectionBetweenNodes: (sourceNodeName: string, targetNodeName: string) =>
cy.get(
`.jtk-connector[data-source-node="${sourceNodeName}"][data-target-node="${targetNodeName}"]`,
cy.ifCanvasVersion(
() =>
cy.get(
`.jtk-connector[data-source-node="${sourceNodeName}"][data-target-node="${targetNodeName}"]`,
),
() =>
cy.get(
`[data-test-id="edge-label-wrapper"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"]`,
),
),
getConnectionActionsBetweenNodes: (sourceNodeName: string, targetNodeName: string) =>
cy.get(
`.connection-actions[data-source-node="${sourceNodeName}"][data-target-node="${targetNodeName}"]`,
cy.ifCanvasVersion(
() =>
cy.get(
`.connection-actions[data-source-node="${sourceNodeName}"][data-target-node="${targetNodeName}"]`,
),
() =>
cy.get(
`[data-test-id="edge-label-wrapper"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"] [data-test-id="canvas-edge-toolbar"]`,
),
),
addStickyButton: () => cy.getByTestId('add-sticky-button'),
stickies: () => cy.getByTestId('sticky'),
@ -128,6 +194,18 @@ export class WorkflowPage extends BasePage {
workflowHistoryButton: () => cy.getByTestId('workflow-history-button'),
colors: () => cy.getByTestId('color'),
contextMenuAction: (action: string) => cy.getByTestId(`context-menu-item-${action}`),
getNodeLeftPosition: (element: JQuery<HTMLElement>) => {
if (isCanvasV2()) {
return parseFloat(element.parent().css('transform').split(',')[4]);
}
return parseFloat(element.css('left'));
},
getNodeTopPosition: (element: JQuery<HTMLElement>) => {
if (isCanvasV2()) {
return parseFloat(element.parent().css('transform').split(',')[5]);
}
return parseFloat(element.css('top'));
},
};
actions = {
@ -332,7 +410,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.nodeViewBackground().trigger('wheel', {
this.getters.nodeView().trigger('wheel', {
force: true,
bubbles: true,
ctrlKey: true,
@ -391,9 +469,12 @@ export class WorkflowPage extends BasePage {
action?: string,
) => {
this.getters.getConnectionBetweenNodes(sourceNodeName, targetNodeName).first().realHover();
this.getters
.getConnectionActionsBetweenNodes(sourceNodeName, targetNodeName)
.find('.add')
const connectionsBetweenNodes = () =>
this.getters.getConnectionActionsBetweenNodes(sourceNodeName, targetNodeName);
cy.ifCanvasVersion(
() => connectionsBetweenNodes().find('.add'),
() => connectionsBetweenNodes().get('[data-test-id="add-connection-button"]'),
)
.first()
.click({ force: true });
@ -401,9 +482,12 @@ export class WorkflowPage extends BasePage {
},
deleteNodeBetweenNodes: (sourceNodeName: string, targetNodeName: string) => {
this.getters.getConnectionBetweenNodes(sourceNodeName, targetNodeName).first().realHover();
this.getters
.getConnectionActionsBetweenNodes(sourceNodeName, targetNodeName)
.find('.delete')
const connectionsBetweenNodes = () =>
this.getters.getConnectionActionsBetweenNodes(sourceNodeName, targetNodeName);
cy.ifCanvasVersion(
() => connectionsBetweenNodes().find('.delete'),
() => connectionsBetweenNodes().get('[data-test-id="delete-connection-button"]'),
)
.first()
.click({ force: true });
},

View file

@ -10,7 +10,7 @@ import {
N8N_AUTH_COOKIE,
} from '../constants';
import { WorkflowPage } from '../pages';
import { getUniqueWorkflowName } from '../utils/workflowUtils';
import { getUniqueWorkflowName, isCanvasV2 } from '../utils/workflowUtils';
Cypress.Commands.add('setAppDate', (targetDate: number | Date) => {
cy.window().then((win) => {
@ -26,6 +26,10 @@ Cypress.Commands.add('getByTestId', (selector, ...args) => {
return cy.get(`[data-test-id="${selector}"]`, ...args);
});
Cypress.Commands.add('ifCanvasVersion', (getterV1, getterV2) => {
return isCanvasV2() ? getterV2() : getterV1();
});
Cypress.Commands.add(
'createFixtureWorkflow',
(fixtureKey: string, workflowName = getUniqueWorkflowName()) => {
@ -70,6 +74,10 @@ Cypress.Commands.add('signin', ({ email, password }) => {
})
.then((response) => {
Cypress.env('currentUserId', response.body.data.id);
cy.window().then((win) => {
win.localStorage.setItem('NodeView.switcher.discovered', 'true'); // @TODO Remove this once the switcher is removed
});
});
});
});

View file

@ -20,6 +20,11 @@ beforeEach(() => {
win.localStorage.setItem('N8N_THEME', 'light');
win.localStorage.setItem('N8N_AUTOCOMPLETE_ONBOARDED', 'true');
win.localStorage.setItem('N8N_MAPPING_ONBOARDED', 'true');
const nodeViewVersion = Cypress.env('NODE_VIEW_VERSION');
if (nodeViewVersion) {
win.localStorage.setItem('NodeView.version', nodeViewVersion);
}
});
cy.intercept('GET', '/rest/settings', (req) => {

View file

@ -28,6 +28,7 @@ declare global {
selector: string,
...args: Array<Partial<Loggable & Timeoutable & Withinable & Shadow> | undefined>
): Chainable<JQuery<HTMLElement>>;
ifCanvasVersion<T1, T2>(getterV1: () => T1, getterV2: () => T2): T1 | T2;
findChildByTestId(childTestId: string): Chainable<JQuery<HTMLElement>>;
/**
* Creates a workflow from the given fixture and optionally renames it.

View file

@ -16,7 +16,7 @@ export function createMockNodeExecutionData(
return {
[name]: {
startTime: new Date().getTime(),
executionTime: 0,
executionTime: 1,
executionStatus,
data: jsonData
? Object.keys(jsonData).reduce((acc, key) => {
@ -33,6 +33,7 @@ export function createMockNodeExecutionData(
}, {} as ITaskDataConnections)
: data,
source: [null],
inputOverride,
...rest,
},
};

View file

@ -3,3 +3,7 @@ import { nanoid } from 'nanoid';
export function getUniqueWorkflowName(workflowNamePrefix?: string) {
return workflowNamePrefix ? `${workflowNamePrefix} ${nanoid(12)}` : nanoid(12);
}
export function isCanvasV2() {
return Cypress.env('NODE_VIEW_VERSION') === 2;
}

View file

@ -31,6 +31,30 @@ WORKDIR /home/node
COPY --from=builder /compiled /usr/local/lib/node_modules/n8n
COPY docker/images/n8n/docker-entrypoint.sh /
# Setup the Task Runner Launcher
ARG TARGETPLATFORM
ARG LAUNCHER_VERSION=0.1.1
ENV N8N_RUNNERS_MODE=internal_launcher \
N8N_RUNNERS_LAUNCHER_PATH=/usr/local/bin/task-runner-launcher
COPY docker/images/n8n/n8n-task-runners.json /etc/n8n-task-runners.json
# First, download, verify, then extract the launcher binary
# Second, chmod with 4555 to allow the use of setuid
# Third, create a new user and group to execute the Task Runners under
RUN \
if [[ "$TARGETPLATFORM" = "linux/amd64" ]]; then export ARCH_NAME="x86_64"; \
elif [[ "$TARGETPLATFORM" = "linux/arm64" ]]; then export ARCH_NAME="aarch64"; fi; \
mkdir /launcher-temp && \
cd /launcher-temp && \
wget https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-$ARCH_NAME-unknown-linux-musl.zip && \
wget https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-$ARCH_NAME-unknown-linux-musl.sha256 && \
sha256sum -c task-runner-launcher-$ARCH_NAME-unknown-linux-musl.sha256 && \
unzip -d $(dirname ${N8N_RUNNERS_LAUNCHER_PATH}) task-runner-launcher-$ARCH_NAME-unknown-linux-musl.zip task-runner-launcher && \
cd - && \
rm -r /launcher-temp && \
chmod 4555 ${N8N_RUNNERS_LAUNCHER_PATH} && \
addgroup -g 2000 task-runner && \
adduser -D -u 2000 -g "Task Runner User" -G task-runner task-runner
RUN \
cd /usr/local/lib/node_modules/n8n && \
npm rebuild sqlite3 && \

View file

@ -22,6 +22,30 @@ RUN set -eux; \
find /usr/local/lib/node_modules/n8n -type f -name "*.ts" -o -name "*.js.map" -o -name "*.vue" | xargs rm -f && \
rm -rf /root/.npm
# Setup the Task Runner Launcher
ARG TARGETPLATFORM
ARG LAUNCHER_VERSION=0.1.1
ENV N8N_RUNNERS_MODE=internal_launcher \
N8N_RUNNERS_LAUNCHER_PATH=/usr/local/bin/task-runner-launcher
COPY n8n-task-runners.json /etc/n8n-task-runners.json
# First, download, verify, then extract the launcher binary
# Second, chmod with 4555 to allow the use of setuid
# Third, create a new user and group to execute the Task Runners under
RUN \
if [[ "$TARGETPLATFORM" = "linux/amd64" ]]; then export ARCH_NAME="x86_64"; \
elif [[ "$TARGETPLATFORM" = "linux/arm64" ]]; then export ARCH_NAME="aarch64"; fi; \
mkdir /launcher-temp && \
cd /launcher-temp && \
wget https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-$ARCH_NAME-unknown-linux-musl.zip && \
wget https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-$ARCH_NAME-unknown-linux-musl.sha256 && \
sha256sum -c task-runner-launcher-$ARCH_NAME-unknown-linux-musl.sha256 && \
unzip -d $(dirname ${N8N_RUNNERS_LAUNCHER_PATH}) task-runner-launcher-$ARCH_NAME-unknown-linux-musl.zip task-runner-launcher && \
cd - && \
rm -r /launcher-temp && \
chmod 4555 ${N8N_RUNNERS_LAUNCHER_PATH} && \
addgroup -g 2000 task-runner && \
adduser -D -u 2000 -g "Task Runner User" -G task-runner task-runner
COPY docker-entrypoint.sh /
RUN \

View file

@ -0,0 +1,22 @@
{
"task-runners": [
{
"runner-type": "javascript",
"workdir": "/home/task-runner",
"command": "/usr/local/bin/node",
"args": ["/usr/local/lib/node_modules/n8n/node_modules/@n8n/task-runner/dist/start.js"],
"allowed-env": [
"PATH",
"N8N_RUNNERS_GRANT_TOKEN",
"N8N_RUNNERS_N8N_URI",
"N8N_RUNNERS_MAX_PAYLOAD",
"N8N_RUNNERS_MAX_CONCURRENCY",
"NODE_FUNCTION_ALLOW_BUILTIN",
"NODE_FUNCTION_ALLOW_EXTERNAL",
"NODE_OPTIONS"
],
"uid": 2000,
"gid": 2000
}
]
}

View file

@ -1,6 +1,6 @@
{
"name": "n8n-monorepo",
"version": "1.63.0",
"version": "1.67.0",
"private": true,
"engines": {
"node": ">=20.15",
@ -43,7 +43,9 @@
"@biomejs/biome": "^1.9.0",
"@n8n_io/eslint-config": "workspace:*",
"@types/jest": "^29.5.3",
"@types/node": "*",
"@types/supertest": "^6.0.2",
"cross-env": "^7.0.3",
"jest": "^29.6.2",
"jest-environment-jsdom": "^29.6.2",
"jest-expect-message": "^1.1.3",
@ -57,8 +59,8 @@
"run-script-os": "^1.0.7",
"supertest": "^7.0.0",
"ts-jest": "^29.1.1",
"tsc-alias": "^1.8.7",
"tsc-watch": "^6.0.4",
"tsc-alias": "^1.8.10",
"tsc-watch": "^6.2.0",
"turbo": "2.1.2",
"typescript": "*",
"zx": "^8.1.4"
@ -82,7 +84,6 @@
},
"patchedDependencies": {
"typedi@0.10.0": "patches/typedi@0.10.0.patch",
"@sentry/cli@2.36.2": "patches/@sentry__cli@2.36.2.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.4.0",
"version": "0.6.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
@ -21,6 +21,7 @@
"dist/**/*"
],
"devDependencies": {
"@n8n/config": "workspace:*",
"n8n-workflow": "workspace:*"
},
"dependencies": {

View file

@ -1,3 +1,4 @@
import type { FrontendBetaFeatures } from '@n8n/config';
import type { ExpressionEvaluatorType, LogLevel, WorkflowSettings } from 'n8n-workflow';
export interface IVersionNotificationSettings {
@ -169,4 +170,5 @@ export interface FrontendSettings {
security: {
blockFileAccessToN8nFiles: boolean;
};
betaFeatures: FrontendBetaFeatures[];
}

View file

@ -1,6 +1,6 @@
# n8n benchmarking tool
Tool for executing benchmarks against an n8n instance. The tool consists of these components:
Tool for executing benchmarks against an n8n instance.
## Directory structure
@ -12,6 +12,39 @@ packages/@n8n/benchmark
├── scripts Orchestration scripts
```
## Benchmarking an existing n8n instance
The easiest way to run the existing benchmark scenarios is to use the benchmark docker image:
```sh
docker pull ghcr.io/n8n-io/n8n-benchmark:latest
# Print the help to list all available flags
docker run ghcr.io/n8n-io/n8n-benchmark:latest run --help
# Run all available benchmark scenarios for 1 minute with 5 concurrent requests
docker run ghcr.io/n8n-io/n8n-benchmark:latest run \
--n8nBaseUrl=https://instance.url \
--n8nUserEmail=InstanceOwner@email.com \
--n8nUserPassword=InstanceOwnerPassword \
--vus=5 \
--duration=1m \
--scenarioFilter SingleWebhook
```
### Using custom scenarios with the Docker image
It is also possible to create your own [benchmark scenarios](#benchmark-scenarios) and load them using the `--testScenariosPath` flag:
```sh
# Assuming your scenarios are located in `./scenarios`, mount them into `/scenarios` in the container
docker run -v ./scenarios:/scenarios ghcr.io/n8n-io/n8n-benchmark:latest run \
--n8nBaseUrl=https://instance.url \
--n8nUserEmail=InstanceOwner@email.com \
--n8nUserPassword=InstanceOwnerPassword \
--vus=5 \
--duration=1m \
--testScenariosPath=/scenarios
```
## Running the entire benchmark suite
The benchmark suite consists of [benchmark scenarios](#benchmark-scenarios) and different [n8n setups](#n8n-setups).

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/n8n-benchmark",
"version": "1.7.0",
"version": "1.8.0",
"description": "Cli for running benchmark tests for n8n",
"main": "dist/index",
"scripts": {
@ -17,7 +17,7 @@
"benchmark-locally": "pnpm benchmark --env local",
"provision-cloud-env": "zx scripts/provision-cloud-env.mjs",
"destroy-cloud-env": "zx scripts/destroy-cloud-env.mjs",
"watch": "concurrently \"tsc -w -p tsconfig.build.json\" \"tsc-alias -w -p tsconfig.build.json\""
"watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\""
},
"engines": {
"node": ">=20.10"
@ -41,10 +41,7 @@
},
"devDependencies": {
"@types/convict": "^6.1.1",
"@types/k6": "^0.52.0",
"@types/node": "^20.14.8",
"tsc-alias": "^1.8.7",
"typescript": "^5.5.2"
"@types/k6": "^0.52.0"
},
"bin": {
"n8n-benchmark": "./bin/n8n-benchmark"

View file

@ -15,6 +15,12 @@ export default function () {
const res = http.post(`${apiBaseUrl}/webhook/binary-files-benchmark`, data);
if (res.status !== 200) {
console.error(
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
);
}
check(res, {
'is status 200': (r) => r.status === 200,
'has correct content type': (r) =>

View file

@ -6,6 +6,12 @@ const apiBaseUrl = __ENV.API_BASE_URL;
export default function () {
const res = http.post(`${apiBaseUrl}/webhook/benchmark-http-node`);
if (res.status !== 200) {
console.error(
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
);
}
check(res, {
'is status 200': (r) => r.status === 200,
'http requests were OK': (r) => {

View file

@ -5,6 +5,13 @@ const apiBaseUrl = __ENV.API_BASE_URL;
export default function () {
const res = http.post(`${apiBaseUrl}/webhook/code-node-benchmark`, {});
if (res.status !== 200) {
console.error(
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
);
}
check(res, {
'is status 200': (r) => r.status === 200,
'has items in response': (r) => {

View file

@ -5,6 +5,13 @@ const apiBaseUrl = __ENV.API_BASE_URL;
export default function () {
const res = http.post(`${apiBaseUrl}/webhook/set-expressions-benchmark`, {});
if (res.status !== 200) {
console.error(
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
);
}
check(res, {
'is status 200': (r) => r.status === 200,
});

View file

@ -3,5 +3,5 @@
"name": "SingleWebhook",
"description": "A single webhook trigger that responds with a 200 status code",
"scenarioData": { "workflowFiles": ["single-webhook.json"] },
"scriptPath": "single-webhook.script.ts"
"scriptPath": "single-webhook.script.js"
}

View file

@ -5,6 +5,13 @@ const apiBaseUrl = __ENV.API_BASE_URL;
export default function () {
const res = http.get(`${apiBaseUrl}/webhook/single-webhook`);
if (res.status !== 200) {
console.error(
`Invalid response. Received status ${res.status}. Body: ${JSON.stringify(res.body)}`,
);
}
check(res, {
'is status 200': (r) => r.status === 200,
});

View file

@ -176,7 +176,7 @@ services:
# Load balancer that acts as an entry point for n8n
n8n:
image: nginx:latest
image: nginx:1.27.2
ports:
- '5678:80'
volumes:

View file

@ -3,6 +3,7 @@ events {}
http {
client_max_body_size 50M;
access_log off;
error_log /dev/stderr warn;
upstream backend {
server n8n_main1:5678;

View file

@ -78,12 +78,6 @@ async function runBenchmarksOnVm(config, benchmarkEnv) {
const bootstrapScriptPath = path.join(scriptsDir, 'bootstrap.sh');
await sshClient.ssh(`chmod a+x ${bootstrapScriptPath} && ${bootstrapScriptPath}`);
// Benchmarking the VM
const vmBenchmarkScriptPath = path.join(scriptsDir, 'vm-benchmark.sh');
await sshClient.ssh(`chmod a+x ${vmBenchmarkScriptPath} && ${vmBenchmarkScriptPath}`, {
verbose: true,
});
// Give some time for the VM to be ready
await sleep(1000);

View file

@ -1,13 +0,0 @@
#!/bin/bash
# Install fio
DEBIAN_FRONTEND=noninteractive sudo apt-get -y install fio > /dev/null
# Run the disk benchmark
fio --name=rand_rw --ioengine=libaio --rw=randrw --rwmixread=70 --bs=4k --numjobs=4 --size=1G --runtime=30 --directory=/n8n --group_reporting
# Remove files
sudo rm /n8n/rand_rw.*
# Uninstall fio
DEBIAN_FRONTEND=noninteractive sudo apt-get -y remove fio > /dev/null

View file

@ -13,6 +13,10 @@ export default class RunCommand extends Command {
static flags = {
testScenariosPath,
scenarioFilter: Flags.string({
char: 'f',
description: 'Filter scenarios by name',
}),
scenarioNamePrefix: Flags.string({
description: 'Prefix for the scenario name',
default: 'Unnamed',
@ -95,7 +99,7 @@ export default class RunCommand extends Command {
flags.scenarioNamePrefix,
);
const allScenarios = scenarioLoader.loadAll(flags.testScenariosPath);
const allScenarios = scenarioLoader.loadAll(flags.testScenariosPath, flags.scenarioFilter);
await scenarioRunner.runManyScenarios(allScenarios);
}

View file

@ -8,7 +8,7 @@ export class ScenarioLoader {
/**
* Loads all scenarios from the given path
*/
loadAll(pathToScenarios: string): Scenario[] {
loadAll(pathToScenarios: string, filter?: string): Scenario[] {
pathToScenarios = path.resolve(pathToScenarios);
const scenarioFolders = fs
.readdirSync(pathToScenarios, { withFileTypes: true })
@ -18,6 +18,9 @@ export class ScenarioLoader {
const scenarios: Scenario[] = [];
for (const folder of scenarioFolders) {
if (filter && !folder.toLowerCase().includes(filter.toLowerCase())) {
continue;
}
const scenarioPath = path.join(pathToScenarios, folder);
const manifestFileName = `${folder}.manifest.json`;
const scenarioManifestPath = path.join(pathToScenarios, folder, manifestFileName);

View file

@ -1,3 +1,5 @@
import { sleep } from 'zx';
import { AuthenticatedN8nApiClient } from '@/n8n-api-client/authenticated-n8n-api-client';
import type { N8nApiClient } from '@/n8n-api-client/n8n-api-client';
import type { ScenarioDataFileLoader } from '@/scenario/scenario-data-loader';
@ -47,6 +49,10 @@ export class ScenarioRunner {
const testData = await this.dataLoader.loadDataForScenario(scenario);
await testDataImporter.importTestScenarioData(testData.workflows);
// Wait for 1s before executing the scenario to ensure that the workflows are activated.
// In multi-main mode it can take some time before the workflow becomes active.
await sleep(1000);
console.log('Executing scenario script');
await this.k6Executor.executeTestScenario(scenario, {
scenarioRunName,

View file

@ -1,11 +1,11 @@
{
"name": "@n8n/chat",
"version": "0.28.0",
"version": "0.30.0",
"scripts": {
"dev": "pnpm run storybook",
"build": "pnpm build:vite && pnpm build:bundle",
"build:vite": "vite build",
"build:bundle": "INCLUDE_VUE=true vite build",
"build:vite": "cross-env vite build",
"build:bundle": "cross-env INCLUDE_VUE=true vite build",
"preview": "vite preview",
"test:dev": "vitest",
"test": "vitest run",

View file

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

View file

@ -0,0 +1,11 @@
import { Config, Env } from '../decorators';
import { StringArray } from '../utils';
export type FrontendBetaFeatures = 'canvas_v2';
@Config
export class FrontendConfig {
/** Which UI experiments to enable. Separate multiple values with a comma `,` */
@Env('N8N_UI_BETA_FEATURES')
betaFeatures: StringArray<FrontendBetaFeatures> = [];
}

View file

@ -0,0 +1,15 @@
import { Config, Env } from '../decorators';
@Config
export class GenericConfig {
/** Default timezone for the n8n instance. Can be overridden on a per-workflow basis. */
@Env('GENERIC_TIMEZONE')
timezone: string = 'America/New_York';
@Env('N8N_RELEASE_TYPE')
releaseChannel: 'stable' | 'beta' | 'nightly' | 'dev' = 'dev';
/** Grace period (in seconds) to wait for components to shut down before process exit. */
@Env('N8N_GRACEFUL_SHUTDOWN_TIMEOUT')
gracefulShutdownTimeout: number = 30;
}

View file

@ -0,0 +1,28 @@
import { Config, Env } from '../decorators';
@Config
export class LicenseConfig {
/** License server URL to retrieve license. */
@Env('N8N_LICENSE_SERVER_URL')
serverUrl: string = 'https://license.n8n.io/v1';
/** Whether autorenewal for licenses is enabled. */
@Env('N8N_LICENSE_AUTO_RENEW_ENABLED')
autoRenewalEnabled: boolean = true;
/** How long (in seconds) before expiry a license should be autorenewed. */
@Env('N8N_LICENSE_AUTO_RENEW_OFFSET')
autoRenewOffset: number = 60 * 60 * 72; // 72 hours
/** Activation key to initialize license. */
@Env('N8N_LICENSE_ACTIVATION_KEY')
activationKey: string = '';
/** Tenant ID used by the license manager SDK, e.g. for self-hosted, sandbox, embed, cloud. */
@Env('N8N_LICENSE_TENANT_ID')
tenantId: number = 1;
/** Ephemeral license certificate. See: https://github.com/n8n-io/license-management?tab=readme-ov-file#concept-ephemeral-entitlements */
@Env('N8N_LICENSE_CERT')
cert: string = '';
}

View file

@ -1,14 +1,19 @@
import { Config, Env, Nested } from '../decorators';
import { StringArray } from '../utils';
/**
* Scopes (areas of functionality) to filter logs by.
*
* `executions` -> execution lifecycle
* `license` -> license SDK
* `scaling` -> scaling mode
*/
export const LOG_SCOPES = ['executions', 'license', 'scaling'] as const;
/** Scopes (areas of functionality) to filter logs by. */
export const LOG_SCOPES = [
'concurrency',
'external-secrets',
'license',
'multi-main-setup',
'pruning',
'pubsub',
'redis',
'scaling',
'waiting-executions',
'task-runner',
] as const;
export type LogScope = (typeof LOG_SCOPES)[number];
@ -59,14 +64,20 @@ export class LoggingConfig {
/**
* Scopes to filter logs by. Nothing is filtered by default.
*
* Currently supported log scopes:
* - `executions`
* Supported log scopes:
*
* - `concurrency`
* - `external-secrets`
* - `license`
* - `multi-main-setup`
* - `pubsub`
* - `redis`
* - `scaling`
* - `waiting-executions`
*
* @example
* `N8N_LOG_SCOPES=license`
* `N8N_LOG_SCOPES=license,executions`
* `N8N_LOG_SCOPES=license,waiting-executions`
*/
@Env('N8N_LOG_SCOPES')
scopes: StringArray<LogScope> = [];

View file

@ -0,0 +1,16 @@
import { Config, Env } from '../decorators';
@Config
export class MultiMainSetupConfig {
/** Whether to enable multi-main setup (if licensed) for scaling mode. */
@Env('N8N_MULTI_MAIN_SETUP_ENABLED')
enabled: boolean = false;
/** Time to live (in seconds) for leader key in multi-main setup. */
@Env('N8N_MULTI_MAIN_SETUP_KEY_TTL')
ttl: number = 10;
/** Interval (in seconds) for leader check in multi-main setup. */
@Env('N8N_MULTI_MAIN_SETUP_CHECK_INTERVAL')
interval: number = 3;
}

View file

@ -0,0 +1,35 @@
import { Config, Env } from '../decorators';
@Config
export class PruningConfig {
/** Whether to delete past executions on a rolling basis. */
@Env('EXECUTIONS_DATA_PRUNE')
isEnabled: boolean = true;
/** How old (hours) a finished execution must be to qualify for soft-deletion. */
@Env('EXECUTIONS_DATA_MAX_AGE')
maxAge: number = 336;
/**
* Max number of finished executions to keep in database. Does not necessarily
* prune to the exact max number. `0` for unlimited.
*/
@Env('EXECUTIONS_DATA_PRUNE_MAX_COUNT')
maxCount: number = 10_000;
/**
* How old (hours) a finished execution must be to qualify for hard-deletion.
* This buffer by default excludes recent executions as the user may need
* them while building a workflow.
*/
@Env('EXECUTIONS_DATA_HARD_DELETE_BUFFER')
hardDeleteBuffer: number = 1;
/** How often (minutes) execution data should be hard-deleted. */
@Env('EXECUTIONS_DATA_PRUNE_HARD_DELETE_INTERVAL')
hardDeleteInterval: number = 15;
/** How often (minutes) execution data should be soft-deleted */
@Env('EXECUTIONS_DATA_PRUNE_SOFT_DELETE_INTERVAL')
softDeleteInterval: number = 60;
}

View file

@ -1,10 +1,21 @@
import { Config, Env } from '../decorators';
/**
* Whether to enable task runners and how to run them
* - internal_childprocess: Task runners are run as a child process and launched by n8n
* - internal_launcher: Task runners are run as a child process and launched by n8n using a separate launch program
* - external: Task runners are run as a separate program not launched by n8n
*/
export type TaskRunnerMode = 'internal_childprocess' | 'internal_launcher' | 'external';
@Config
export class TaskRunnersConfig {
@Env('N8N_RUNNERS_ENABLED')
enabled: boolean = false;
// Defaults to true for now
@Env('N8N_RUNNERS_DISABLED')
disabled: boolean = true;
@Env('N8N_RUNNERS_MODE')
mode: TaskRunnerMode = 'internal_childprocess';
@Env('N8N_RUNNERS_PATH')
path: string = '/runners';
@ -18,5 +29,28 @@ export class TaskRunnersConfig {
/** IP address task runners server should listen on */
@Env('N8N_RUNNERS_SERVER_LISTEN_ADDRESS')
listen_address: string = '127.0.0.1';
listenAddress: string = '127.0.0.1';
/** Maximum size of a payload sent to the runner in bytes, Default 1G */
@Env('N8N_RUNNERS_MAX_PAYLOAD')
maxPayload: number = 1024 * 1024 * 1024;
@Env('N8N_RUNNERS_LAUNCHER_PATH')
launcherPath: string = '';
/** Which task runner to launch from the config */
@Env('N8N_RUNNERS_LAUNCHER_RUNNER')
launcherRunner: string = 'javascript';
/** The --max-old-space-size option to use for the runner (in MB). Default means node.js will determine it based on the available memory. */
@Env('N8N_RUNNERS_MAX_OLD_SPACE_SIZE')
maxOldSpaceSize: string = '';
/** How many concurrent tasks can a runner execute at a time */
@Env('N8N_RUNNERS_MAX_CONCURRENCY')
maxConcurrency: number = 5;
/** Should the output of deduplication be asserted for correctness */
@Env('N8N_RUNNERS_ASSERT_DEDUPLICATION_OUTPUT')
assertDeduplicationOutput: boolean = false;
}

View file

@ -82,10 +82,6 @@ class BullConfig {
@Nested
redis: RedisConfig;
/** How often (in seconds) to poll the Bull queue to identify executions finished during a Redis crash. `0` to disable. May increase Redis traffic significantly. */
@Env('QUEUE_RECOVERY_INTERVAL')
queueRecoveryInterval: number = 60; // watchdog interval
/** @deprecated How long (in seconds) a worker must wait for active executions to finish before exiting. Use `N8N_GRACEFUL_SHUTDOWN_TIMEOUT` instead */
@Env('QUEUE_WORKER_TIMEOUT')
gracefulShutdownTimeout: number = 30;

View file

@ -0,0 +1,27 @@
import { Config, Env } from '../decorators';
@Config
export class SecurityConfig {
/**
* Which directories to limit n8n's access to. Separate multiple dirs with semicolon `;`.
*
* @example N8N_RESTRICT_FILE_ACCESS_TO=/home/user/.n8n;/home/user/n8n-data
*/
@Env('N8N_RESTRICT_FILE_ACCESS_TO')
restrictFileAccessTo: string = '';
/**
* Whether to block access to all files at:
* - the ".n8n" directory,
* - the static cache dir at ~/.cache/n8n/public, and
* - user-defined config files.
*/
@Env('N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES')
blockFileAccessToN8nFiles: boolean = true;
/**
* In a [security audit](https://docs.n8n.io/hosting/securing/security-audit/), how many days for a workflow to be considered abandoned if not executed.
*/
@Env('N8N_SECURITY_AUDIT_DAYS_ABANDONED_WORKFLOW')
daysAbandonedWorkflow: number = 90;
}

View file

@ -5,12 +5,16 @@ import { EndpointsConfig } from './configs/endpoints.config';
import { EventBusConfig } from './configs/event-bus.config';
import { ExternalSecretsConfig } from './configs/external-secrets.config';
import { ExternalStorageConfig } from './configs/external-storage.config';
import { GenericConfig } from './configs/generic.config';
import { LicenseConfig } from './configs/license.config';
import { LoggingConfig } from './configs/logging.config';
import { MultiMainSetupConfig } from './configs/multi-main-setup.config';
import { NodesConfig } from './configs/nodes.config';
import { PruningConfig } from './configs/pruning.config';
import { PublicApiConfig } from './configs/public-api.config';
import { TaskRunnersConfig } from './configs/runners.config';
export { TaskRunnersConfig } from './configs/runners.config';
import { ScalingModeConfig } from './configs/scaling-mode.config';
import { SecurityConfig } from './configs/security.config';
import { SentryConfig } from './configs/sentry.config';
import { TemplatesConfig } from './configs/templates.config';
import { UserManagementConfig } from './configs/user-management.config';
@ -18,6 +22,11 @@ import { VersionNotificationsConfig } from './configs/version-notifications.conf
import { WorkflowsConfig } from './configs/workflows.config';
import { Config, Env, Nested } from './decorators';
export { Config, Env, Nested } from './decorators';
export { TaskRunnersConfig } from './configs/runners.config';
export { SecurityConfig } from './configs/security.config';
export { PruningConfig } from './configs/pruning.config';
export { FrontendBetaFeatures, FrontendConfig } from './configs/frontend.config';
export { LOG_SCOPES } from './configs/logging.config';
export type { LogScope } from './configs/logging.config';
@ -93,4 +102,19 @@ export class GlobalConfig {
@Nested
taskRunners: TaskRunnersConfig;
@Nested
multiMainSetup: MultiMainSetupConfig;
@Nested
generic: GenericConfig;
@Nested
license: LicenseConfig;
@Nested
security: SecurityConfig;
@Nested
pruning: PruningConfig;
}

View file

@ -211,7 +211,6 @@ describe('GlobalConfig', () => {
clusterNodes: '',
tls: false,
},
queueRecoveryInterval: 60,
gracefulShutdownTimeout: 30,
prefix: 'bull',
settings: {
@ -223,11 +222,18 @@ describe('GlobalConfig', () => {
},
},
taskRunners: {
disabled: true,
enabled: false,
mode: 'internal_childprocess',
path: '/runners',
authToken: '',
listen_address: '127.0.0.1',
listenAddress: '127.0.0.1',
maxPayload: 1024 * 1024 * 1024,
port: 5679,
launcherPath: '',
launcherRunner: 'javascript',
maxOldSpaceSize: '',
maxConcurrency: 5,
assertDeduplicationOutput: false,
},
sentry: {
backendDsn: '',
@ -243,6 +249,37 @@ describe('GlobalConfig', () => {
},
scopes: [],
},
multiMainSetup: {
enabled: false,
ttl: 10,
interval: 3,
},
generic: {
timezone: 'America/New_York',
releaseChannel: 'dev',
gracefulShutdownTimeout: 30,
},
license: {
serverUrl: 'https://license.n8n.io/v1',
autoRenewalEnabled: true,
autoRenewOffset: 60 * 60 * 72,
activationKey: '',
tenantId: 1,
cert: '',
},
security: {
restrictFileAccessTo: '',
blockFileAccessToN8nFiles: true,
daysAbandonedWorkflow: 90,
},
pruning: {
isEnabled: true,
maxAge: 336,
maxCount: 10_000,
hardDeleteBuffer: 1,
hardDeleteInterval: 15,
softDeleteInterval: 60,
},
};
it('should use all default values when no env variables are defined', () => {

View file

@ -0,0 +1,21 @@
const sharedOptions = require('@n8n_io/eslint-config/shared');
/**
* @type {import('@types/eslint').ESLint.ConfigData}
*/
module.exports = {
extends: ['@n8n_io/eslint-config/node'],
...sharedOptions(__dirname),
ignorePatterns: ['jest.config.js'],
rules: {
'unicorn/filename-case': ['error', { case: 'kebabCase' }],
'@typescript-eslint/no-duplicate-imports': 'off',
'import/no-cycle': 'off',
'n8n-local-rules/no-plain-errors': 'off',
complexity: 'error',
},
};

View file

@ -0,0 +1,4 @@
node_modules
dist
coverage
test/output

View file

@ -0,0 +1,3 @@
src
tsconfig*
test

View file

@ -0,0 +1,16 @@
ISC License
Copyright (c) 2024, n8n
Copyright (c) 2021, Stefan Terdell
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

View file

@ -0,0 +1,34 @@
# Json-Schema-to-Zod
A package to convert JSON schema (draft 4+) objects into Zod schemas in the form of Zod objects at runtime.
## Installation
```sh
npm install @n8n/json-schema-to-zod
```
### Simple example
```typescript
import { jsonSchemaToZod } from "json-schema-to-zod";
const jsonSchema = {
type: "object",
properties: {
hello: {
type: "string",
},
},
};
const zodSchema = jsonSchemaToZod(myObject);
```
### Overriding a parser
You can pass a function to the `overrideParser` option, which represents a function that receives the current schema node and the reference object, and should return a zod object when it wants to replace a default output. If the default output should be used for the node just return undefined.
## Acknowledgements
This is a fork of [`json-schema-to-zod`](https://github.com/StefanTerdell/json-schema-to-zod).

View file

@ -0,0 +1,5 @@
/** @type {import('jest').Config} */
module.exports = {
...require('../../../jest.config'),
setupFilesAfterEnv: ['<rootDir>/test/extend-expect.ts'],
};

View file

@ -0,0 +1,68 @@
{
"name": "@n8n/json-schema-to-zod",
"version": "1.1.0",
"description": "Converts JSON schema objects into Zod schemas",
"types": "./dist/types/index.d.ts",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"exports": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/types/index.d.ts",
"default": "./dist/cjs/index.js"
}
},
"scripts": {
"clean": "rimraf dist .turbo",
"typecheck": "tsc --noEmit",
"dev": "tsc -w",
"format": "biome format --write src",
"format:check": "biome ci src",
"lint": "eslint . --quiet",
"lintfix": "eslint . --fix",
"build:types": "tsc -p tsconfig.types.json",
"build:cjs": "tsc -p tsconfig.cjs.json && node postcjs.js",
"build:esm": "tsc -p tsconfig.esm.json && node postesm.js",
"build": "rimraf ./dist && pnpm run build:types && pnpm run build:cjs && pnpm run build:esm",
"dry": "pnpm run build && pnpm pub --dry-run",
"test": "jest",
"test:watch": "jest --watch"
},
"keywords": [
"zod",
"json",
"schema",
"converter",
"cli"
],
"author": "Stefan Terdell",
"contributors": [
"Chen (https://github.com/werifu)",
"Nuno Carduso (https://github.com/ncardoso-barracuda)",
"Lars Strojny (https://github.com/lstrojny)",
"Navtoj Chahal (https://github.com/navtoj)",
"Ben McCann (https://github.com/benmccann)",
"Dmitry Zakharov (https://github.com/DZakh)",
"Michel Turpin (https://github.com/grimly)",
"David Barratt (https://github.com/davidbarratt)",
"pevisscher (https://github.com/pevisscher)",
"Aidin Abedi (https://github.com/aidinabedi)",
"Brett Zamir (https://github.com/brettz9)",
"n8n (https://github.com/n8n-io)"
],
"license": "ISC",
"repository": {
"type": "git",
"url": "https://github.com/n8n-io/n8n"
},
"peerDependencies": {
"zod": "^3.0.0"
},
"devDependencies": {
"@types/json-schema": "^7.0.15",
"zod": "catalog:"
}
}

View file

@ -0,0 +1 @@
require('fs').writeFileSync('./dist/cjs/package.json', '{"type":"commonjs"}', 'utf-8');

View file

@ -0,0 +1 @@
require('fs').writeFileSync('./dist/esm/package.json', '{"type":"module"}', 'utf-8');

View file

@ -0,0 +1,2 @@
export type * from './types';
export { jsonSchemaToZod } from './json-schema-to-zod.js';

View file

@ -0,0 +1,15 @@
import type { z } from 'zod';
import { parseSchema } from './parsers/parse-schema';
import type { JsonSchemaToZodOptions, JsonSchema } from './types';
export const jsonSchemaToZod = <T extends z.ZodTypeAny = z.ZodTypeAny>(
schema: JsonSchema,
options: JsonSchemaToZodOptions = {},
): T => {
return parseSchema(schema, {
path: [],
seen: new Map(),
...options,
}) as T;
};

View file

@ -0,0 +1,46 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, JsonSchema, Refs } from '../types';
import { half } from '../utils/half';
const originalIndex = Symbol('Original index');
const ensureOriginalIndex = (arr: JsonSchema[]) => {
const newArr = [];
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
if (typeof item === 'boolean') {
newArr.push(item ? { [originalIndex]: i } : { [originalIndex]: i, not: {} });
} else if (originalIndex in item) {
return arr;
} else {
newArr.push({ ...item, [originalIndex]: i });
}
}
return newArr;
};
export function parseAllOf(
jsonSchema: JsonSchemaObject & { allOf: JsonSchema[] },
refs: Refs,
): z.ZodTypeAny {
if (jsonSchema.allOf.length === 0) {
return z.never();
}
if (jsonSchema.allOf.length === 1) {
const item = jsonSchema.allOf[0];
return parseSchema(item, {
...refs,
path: [...refs.path, 'allOf', (item as never)[originalIndex]],
});
}
const [left, right] = half(ensureOriginalIndex(jsonSchema.allOf));
return z.intersection(parseAllOf({ allOf: left }, refs), parseAllOf({ allOf: right }, refs));
}

View file

@ -0,0 +1,19 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, JsonSchema, Refs } from '../types';
export const parseAnyOf = (jsonSchema: JsonSchemaObject & { anyOf: JsonSchema[] }, refs: Refs) => {
return jsonSchema.anyOf.length
? jsonSchema.anyOf.length === 1
? parseSchema(jsonSchema.anyOf[0], {
...refs,
path: [...refs.path, 'anyOf', 0],
})
: z.union(
jsonSchema.anyOf.map((schema, i) =>
parseSchema(schema, { ...refs, path: [...refs.path, 'anyOf', i] }),
) as [z.ZodTypeAny, z.ZodTypeAny],
)
: z.any();
};

View file

@ -0,0 +1,34 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, Refs } from '../types';
import { extendSchemaWithMessage } from '../utils/extend-schema';
export const parseArray = (jsonSchema: JsonSchemaObject & { type: 'array' }, refs: Refs) => {
if (Array.isArray(jsonSchema.items)) {
return z.tuple(
jsonSchema.items.map((v, i) =>
parseSchema(v, { ...refs, path: [...refs.path, 'items', i] }),
) as [z.ZodTypeAny],
);
}
let zodSchema = !jsonSchema.items
? z.array(z.any())
: z.array(parseSchema(jsonSchema.items, { ...refs, path: [...refs.path, 'items'] }));
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'minItems',
(zs, minItems, errorMessage) => zs.min(minItems, errorMessage),
);
zodSchema = extendSchemaWithMessage(
zodSchema,
jsonSchema,
'maxItems',
(zs, maxItems, errorMessage) => zs.max(maxItems, errorMessage),
);
return zodSchema;
};

View file

@ -0,0 +1,7 @@
import { z } from 'zod';
import type { JsonSchemaObject } from '../types';
export const parseBoolean = (_jsonSchema: JsonSchemaObject & { type: 'boolean' }) => {
return z.boolean();
};

View file

@ -0,0 +1,7 @@
import { z } from 'zod';
import type { JsonSchemaObject, Serializable } from '../types';
export const parseConst = (jsonSchema: JsonSchemaObject & { const: Serializable }) => {
return z.literal(jsonSchema.const as z.Primitive);
};

View file

@ -0,0 +1,7 @@
import { z } from 'zod';
import type { JsonSchemaObject } from '../types';
export const parseDefault = (_jsonSchema: JsonSchemaObject) => {
return z.any();
};

View file

@ -0,0 +1,25 @@
import { z } from 'zod';
import type { JsonSchemaObject, Serializable } from '../types';
export const parseEnum = (jsonSchema: JsonSchemaObject & { enum: Serializable[] }) => {
if (jsonSchema.enum.length === 0) {
return z.never();
}
if (jsonSchema.enum.length === 1) {
// union does not work when there is only one element
return z.literal(jsonSchema.enum[0] as z.Primitive);
}
if (jsonSchema.enum.every((x) => typeof x === 'string')) {
return z.enum(jsonSchema.enum as [string]);
}
return z.union(
jsonSchema.enum.map((x) => z.literal(x as z.Primitive)) as unknown as [
z.ZodTypeAny,
z.ZodTypeAny,
],
);
};

View file

@ -0,0 +1,31 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, JsonSchema, Refs } from '../types';
export const parseIfThenElse = (
jsonSchema: JsonSchemaObject & {
if: JsonSchema;
then: JsonSchema;
else: JsonSchema;
},
refs: Refs,
) => {
const $if = parseSchema(jsonSchema.if, { ...refs, path: [...refs.path, 'if'] });
const $then = parseSchema(jsonSchema.then, {
...refs,
path: [...refs.path, 'then'],
});
const $else = parseSchema(jsonSchema.else, {
...refs,
path: [...refs.path, 'else'],
});
return z.union([$then, $else]).superRefine((value, ctx) => {
const result = $if.safeParse(value).success ? $then.safeParse(value) : $else.safeParse(value);
if (!result.success) {
result.error.errors.forEach((error) => ctx.addIssue(error));
}
});
};

View file

@ -0,0 +1,16 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchema, JsonSchemaObject, Refs } from '../types';
export const parseMultipleType = (
jsonSchema: JsonSchemaObject & { type: string[] },
refs: Refs,
) => {
return z.union(
jsonSchema.type.map((type) => parseSchema({ ...jsonSchema, type } as JsonSchema, refs)) as [
z.ZodTypeAny,
z.ZodTypeAny,
],
);
};

View file

@ -0,0 +1,15 @@
import { z } from 'zod';
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, JsonSchema, Refs } from '../types';
export const parseNot = (jsonSchema: JsonSchemaObject & { not: JsonSchema }, refs: Refs) => {
return z.any().refine(
(value) =>
!parseSchema(jsonSchema.not, {
...refs,
path: [...refs.path, 'not'],
}).safeParse(value).success,
'Invalid input: Should NOT be valid against schema',
);
};

View file

@ -0,0 +1,7 @@
import { z } from 'zod';
import type { JsonSchemaObject } from '../types';
export const parseNull = (_jsonSchema: JsonSchemaObject & { type: 'null' }) => {
return z.null();
};

View file

@ -0,0 +1,10 @@
import { parseSchema } from './parse-schema';
import type { JsonSchemaObject, Refs } from '../types';
import { omit } from '../utils/omit';
/**
* For compatibility with open api 3.0 nullable
*/
export const parseNullable = (jsonSchema: JsonSchemaObject & { nullable: true }, refs: Refs) => {
return parseSchema(omit(jsonSchema, 'nullable'), refs, true).nullable();
};

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