mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge remote-tracking branch 'origin/master' into ado-2597-incorrect-expression-when-mapping-from-non-immediate-node-in
This commit is contained in:
commit
a8d81149b3
6
.github/workflows/e2e-reusable.yml
vendored
6
.github/workflows/e2e-reusable.yml
vendored
|
@ -41,6 +41,11 @@ on:
|
||||||
description: 'PR number to run tests for.'
|
description: 'PR number to run tests for.'
|
||||||
required: false
|
required: false
|
||||||
type: number
|
type: number
|
||||||
|
node_view_version:
|
||||||
|
description: 'Node View version to run tests with.'
|
||||||
|
required: false
|
||||||
|
default: '1'
|
||||||
|
type: string
|
||||||
secrets:
|
secrets:
|
||||||
CYPRESS_RECORD_KEY:
|
CYPRESS_RECORD_KEY:
|
||||||
description: 'Cypress record key.'
|
description: 'Cypress record key.'
|
||||||
|
@ -160,6 +165,7 @@ jobs:
|
||||||
spec: '${{ inputs.spec }}'
|
spec: '${{ inputs.spec }}'
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --dns-result-order=ipv4first
|
NODE_OPTIONS: --dns-result-order=ipv4first
|
||||||
|
CYPRESS_NODE_VIEW_VERSION: ${{ inputs.node_view_version }}
|
||||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
E2E_TESTS: true
|
E2E_TESTS: true
|
||||||
|
|
6
.github/workflows/e2e-tests.yml
vendored
6
.github/workflows/e2e-tests.yml
vendored
|
@ -27,6 +27,11 @@ on:
|
||||||
description: 'URL to call after workflow is done.'
|
description: 'URL to call after workflow is done.'
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
|
node_view_version:
|
||||||
|
description: 'Node View version to run tests with.'
|
||||||
|
required: false
|
||||||
|
default: '1'
|
||||||
|
type: string
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
calls-start-url:
|
calls-start-url:
|
||||||
|
@ -46,6 +51,7 @@ jobs:
|
||||||
branch: ${{ github.event.inputs.branch || 'master' }}
|
branch: ${{ github.event.inputs.branch || 'master' }}
|
||||||
user: ${{ github.event.inputs.user || 'PR User' }}
|
user: ${{ github.event.inputs.user || 'PR User' }}
|
||||||
spec: ${{ github.event.inputs.spec || 'e2e/*' }}
|
spec: ${{ github.event.inputs.spec || 'e2e/*' }}
|
||||||
|
node_view_version: ${{ github.event.inputs.node_view_version || '1' }}
|
||||||
secrets:
|
secrets:
|
||||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||||
|
|
||||||
|
|
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
|
@ -7,6 +7,7 @@
|
||||||
"EditorConfig.EditorConfig",
|
"EditorConfig.EditorConfig",
|
||||||
"esbenp.prettier-vscode",
|
"esbenp.prettier-vscode",
|
||||||
"mjmlio.vscode-mjml",
|
"mjmlio.vscode-mjml",
|
||||||
"Vue.volar"
|
"Vue.volar",
|
||||||
|
"vitest.explorer"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
93
CHANGELOG.md
93
CHANGELOG.md
|
@ -1,3 +1,96 @@
|
||||||
|
# [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)
|
# [1.63.0](https://github.com/n8n-io/n8n/compare/n8n@1.62.1...n8n@1.63.0) (2024-10-09)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -68,7 +68,7 @@ If you already have VS Code and Docker installed, you can click [here](https://v
|
||||||
|
|
||||||
#### Node.js
|
#### 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
|
#### pnpm
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,7 @@ describe('Undo/Redo', () => {
|
||||||
WorkflowPage.actions.visit();
|
WorkflowPage.actions.visit();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FIXME: Canvas V2: Fix redo connections
|
||||||
it('should undo/redo adding node in the middle', () => {
|
it('should undo/redo adding node in the middle', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
|
@ -114,6 +115,7 @@ describe('Undo/Redo', () => {
|
||||||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FIXME: Canvas V2: Fix moving of nodes via e2e tests
|
||||||
it('should undo/redo moving nodes', () => {
|
it('should undo/redo moving nodes', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_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', () => {
|
it('should undo/redo deleting a connection using context menu', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
WorkflowPage.getters.nodeConnections().realHover();
|
WorkflowPage.actions.deleteNodeBetweenNodes(SCHEDULE_TRIGGER_NODE_NAME, CODE_NODE_NAME);
|
||||||
cy.get('.connection-actions .delete')
|
|
||||||
.filter(':visible')
|
|
||||||
.should('be.visible')
|
|
||||||
.click({ force: true });
|
|
||||||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||||
WorkflowPage.actions.hitUndo();
|
WorkflowPage.actions.hitUndo();
|
||||||
WorkflowPage.getters.nodeConnections().should('have.length', 1);
|
WorkflowPage.getters.nodeConnections().should('have.length', 1);
|
||||||
WorkflowPage.actions.hitRedo();
|
WorkflowPage.actions.hitRedo();
|
||||||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
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', () => {
|
it('should undo/redo deleting a connection by moving it away', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
|
@ -206,6 +204,7 @@ describe('Undo/Redo', () => {
|
||||||
WorkflowPage.getters.disabledNodes().should('have.length', 2);
|
WorkflowPage.getters.disabledNodes().should('have.length', 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FIXME: Canvas V2: Fix undo renaming node
|
||||||
it('should undo/redo renaming node using keyboard shortcut', () => {
|
it('should undo/redo renaming node using keyboard shortcut', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_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', () => {
|
it('should undo/redo multiple steps', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
|
|
|
@ -16,6 +16,7 @@ describe('Canvas Actions', () => {
|
||||||
WorkflowPage.actions.visit();
|
WorkflowPage.actions.visit();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FIXME: Canvas V2: Missing execute button if no nodes
|
||||||
it('should render canvas', () => {
|
it('should render canvas', () => {
|
||||||
WorkflowPage.getters.nodeViewRoot().should('be.visible');
|
WorkflowPage.getters.nodeViewRoot().should('be.visible');
|
||||||
WorkflowPage.getters.canvasPlusButton().should('be.visible');
|
WorkflowPage.getters.canvasPlusButton().should('be.visible');
|
||||||
|
@ -25,10 +26,11 @@ describe('Canvas Actions', () => {
|
||||||
WorkflowPage.getters.executeWorkflowButton().should('be.visible');
|
WorkflowPage.getters.executeWorkflowButton().should('be.visible');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FIXME: Canvas V2: Fix changing of connection
|
||||||
it('should connect and disconnect a simple node', () => {
|
it('should connect and disconnect a simple node', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
|
||||||
WorkflowPage.getters.nodeViewBackground().click(600, 200, { force: true });
|
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.getters.nodeViewBackground().click(600, 400, { force: true });
|
||||||
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
|
||||||
|
@ -40,16 +42,16 @@ describe('Canvas Actions', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
WorkflowPage.getters
|
WorkflowPage.getters
|
||||||
.canvasNodeInputEndpointByName(`${EDIT_FIELDS_SET_NODE_NAME}1`)
|
.getConnectionBetweenNodes(MANUAL_TRIGGER_NODE_DISPLAY_NAME, `${EDIT_FIELDS_SET_NODE_NAME}1`)
|
||||||
.should('have.class', 'jtk-endpoint-connected');
|
.should('be.visible');
|
||||||
|
|
||||||
cy.get('.jtk-connector').should('have.length', 1);
|
WorkflowPage.getters.nodeConnections().should('have.length', 1);
|
||||||
// Disconnect Set1
|
// Disconnect Set1
|
||||||
cy.drag(
|
cy.drag(
|
||||||
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
|
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
|
||||||
[-200, 100],
|
[-200, 100],
|
||||||
);
|
);
|
||||||
cy.get('.jtk-connector').should('have.length', 0);
|
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add first step', () => {
|
it('should add first step', () => {
|
||||||
|
@ -74,7 +76,7 @@ describe('Canvas Actions', () => {
|
||||||
|
|
||||||
it('should add a connected node using plus endpoint', () => {
|
it('should add a connected node using plus endpoint', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
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().should('be.visible');
|
||||||
WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME);
|
WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME);
|
||||||
WorkflowPage.getters.nodeCreatorSearchBar().type('{enter}');
|
WorkflowPage.getters.nodeCreatorSearchBar().type('{enter}');
|
||||||
|
@ -85,7 +87,7 @@ describe('Canvas Actions', () => {
|
||||||
|
|
||||||
it('should add a connected node dragging from node creator', () => {
|
it('should add a connected node dragging from node creator', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
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().should('be.visible');
|
||||||
WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME);
|
WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME);
|
||||||
cy.drag(WorkflowPage.getters.nodeCreatorNodeItems().first(), [100, 100], {
|
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', () => {
|
it('should open a category when trying to drag and drop it on the canvas', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
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().should('be.visible');
|
||||||
WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME);
|
WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME);
|
||||||
cy.drag(WorkflowPage.getters.nodeCreatorActionItems().first(), [100, 100], {
|
cy.drag(WorkflowPage.getters.nodeCreatorActionItems().first(), [100, 100], {
|
||||||
|
@ -114,7 +116,7 @@ describe('Canvas Actions', () => {
|
||||||
it('should add disconnected node if nothing is selected', () => {
|
it('should add disconnected node if nothing is selected', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||||
// Deselect nodes
|
// Deselect nodes
|
||||||
WorkflowPage.getters.nodeViewBackground().click({ force: true });
|
WorkflowPage.getters.nodeView().click({ force: true });
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
WorkflowPage.getters.canvasNodes().should('have.length', 2);
|
WorkflowPage.getters.canvasNodes().should('have.length', 2);
|
||||||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||||
|
@ -136,10 +138,10 @@ describe('Canvas Actions', () => {
|
||||||
WorkflowPage.getters.nodeConnections().should('have.length', 3);
|
WorkflowPage.getters.nodeConnections().should('have.length', 3);
|
||||||
|
|
||||||
WorkflowPage.getters.canvasNodeByName(EDIT_FIELDS_SET_NODE_NAME).then(($editFieldsNode) => {
|
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) => {
|
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);
|
expect(httpNodeLeft).to.be.lessThan(editFieldsNodeLeft);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -159,10 +161,12 @@ describe('Canvas Actions', () => {
|
||||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
WorkflowPage.getters.nodeConnections().first().realHover();
|
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);
|
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', () => {
|
it('should delete a connection by moving it away from endpoint', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||||
|
@ -216,10 +220,10 @@ describe('Canvas Actions', () => {
|
||||||
WorkflowPage.actions.hitSelectAll();
|
WorkflowPage.actions.hitSelectAll();
|
||||||
|
|
||||||
WorkflowPage.actions.hitCopy();
|
WorkflowPage.actions.hitCopy();
|
||||||
successToast().should('contain', 'Copied!');
|
successToast().should('contain', 'Copied to clipboard');
|
||||||
|
|
||||||
WorkflowPage.actions.copyNode(CODE_NODE_NAME);
|
WorkflowPage.actions.copyNode(CODE_NODE_NAME);
|
||||||
successToast().should('contain', 'Copied!');
|
successToast().should('contain', 'Copied to clipboard');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should select/deselect all nodes', () => {
|
it('should select/deselect all nodes', () => {
|
||||||
|
@ -231,17 +235,31 @@ describe('Canvas Actions', () => {
|
||||||
WorkflowPage.getters.selectedNodes().should('have.length', 0);
|
WorkflowPage.getters.selectedNodes().should('have.length', 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FIXME: Canvas V2: Selection via arrow keys is broken
|
||||||
it('should select nodes using arrow keys', () => {
|
it('should select nodes using arrow keys', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
cy.wait(500);
|
cy.wait(500);
|
||||||
cy.get('body').type('{leftArrow}');
|
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}');
|
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', () => {
|
it('should select nodes using shift and arrow keys', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||||
|
@ -251,6 +269,7 @@ describe('Canvas Actions', () => {
|
||||||
WorkflowPage.getters.selectedNodes().should('have.length', 2);
|
WorkflowPage.getters.selectedNodes().should('have.length', 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FIXME: Canvas V2: Fix select & deselect
|
||||||
it('should not break lasso selection when dragging node action buttons', () => {
|
it('should not break lasso selection when dragging node action buttons', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.getters
|
WorkflowPage.getters
|
||||||
|
@ -262,6 +281,7 @@ describe('Canvas Actions', () => {
|
||||||
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
|
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FIXME: Canvas V2: Fix select & deselect
|
||||||
it('should not break lasso selection with multiple clicks on node action buttons', () => {
|
it('should not break lasso selection with multiple clicks on node action buttons', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
|
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
|
||||||
|
|
|
@ -9,6 +9,7 @@ import {
|
||||||
} from './../constants';
|
} from './../constants';
|
||||||
import { NDV, WorkflowExecutionsTab } from '../pages';
|
import { NDV, WorkflowExecutionsTab } from '../pages';
|
||||||
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
|
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
|
||||||
|
import { isCanvasV2 } from '../utils/workflowUtils';
|
||||||
|
|
||||||
const WorkflowPage = new WorkflowPageClass();
|
const WorkflowPage = new WorkflowPageClass();
|
||||||
const ExecutionsTab = new WorkflowExecutionsTab();
|
const ExecutionsTab = new WorkflowExecutionsTab();
|
||||||
|
@ -52,15 +53,15 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
||||||
cy.reload();
|
cy.reload();
|
||||||
cy.waitForLoad();
|
cy.waitForLoad();
|
||||||
// Make sure outputless switch was connected correctly
|
// Make sure outputless switch was connected correctly
|
||||||
cy.get(
|
WorkflowPage.getters
|
||||||
`[data-target-node="${SWITCH_NODE_NAME}1"][data-source-node="${EDIT_FIELDS_SET_NODE_NAME}3"]`,
|
.getConnectionBetweenNodes(`${EDIT_FIELDS_SET_NODE_NAME}3`, `${SWITCH_NODE_NAME}1`)
|
||||||
).should('be.visible');
|
.should('exist');
|
||||||
// Make sure all connections are there after reload
|
// Make sure all connections are there after reload
|
||||||
for (let i = 0; i < desiredOutputs; i++) {
|
for (let i = 0; i < desiredOutputs; i++) {
|
||||||
const setName = `${EDIT_FIELDS_SET_NODE_NAME}${i > 0 ? i : ''}`;
|
const setName = `${EDIT_FIELDS_SET_NODE_NAME}${i > 0 ? i : ''}`;
|
||||||
WorkflowPage.getters
|
WorkflowPage.getters
|
||||||
.canvasNodeInputEndpointByName(setName)
|
.getConnectionBetweenNodes(`${SWITCH_NODE_NAME}`, setName)
|
||||||
.should('have.class', 'jtk-endpoint-connected');
|
.should('exist');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -69,9 +70,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
||||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true);
|
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true);
|
||||||
WorkflowPage.getters
|
WorkflowPage.getters.nodeView().click((i + 1) * 200, (i + 1) * 200, { force: true });
|
||||||
.nodeViewBackground()
|
|
||||||
.click((i + 1) * 200, (i + 1) * 200, { force: true });
|
|
||||||
}
|
}
|
||||||
WorkflowPage.actions.zoomToFit();
|
WorkflowPage.actions.zoomToFit();
|
||||||
|
|
||||||
|
@ -84,8 +83,6 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
||||||
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
|
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
|
// Connect Set1 and Set2 to merge
|
||||||
cy.draganddrop(
|
cy.draganddrop(
|
||||||
WorkflowPage.getters.getEndpointSelector('plus', EDIT_FIELDS_SET_NODE_NAME),
|
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('plus', `${EDIT_FIELDS_SET_NODE_NAME}1`),
|
||||||
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 1),
|
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 1),
|
||||||
);
|
);
|
||||||
|
const checkConnections = () => {
|
||||||
cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 4);
|
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
|
// Make sure all connections are there after save & reload
|
||||||
WorkflowPage.actions.saveWorkflowOnButtonClick();
|
WorkflowPage.actions.saveWorkflowOnButtonClick();
|
||||||
cy.reload();
|
cy.reload();
|
||||||
cy.waitForLoad();
|
cy.waitForLoad();
|
||||||
|
checkConnections();
|
||||||
cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 4);
|
// cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 4);
|
||||||
WorkflowPage.actions.executeWorkflow();
|
WorkflowPage.actions.executeWorkflow();
|
||||||
WorkflowPage.getters.stopExecutionButton().should('not.exist');
|
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
|
// 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', () => {
|
it('should add nodes and check execution success', () => {
|
||||||
|
@ -120,16 +133,42 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
||||||
WorkflowPage.actions.zoomToFit();
|
WorkflowPage.actions.zoomToFit();
|
||||||
WorkflowPage.actions.executeWorkflow();
|
WorkflowPage.actions.executeWorkflow();
|
||||||
|
|
||||||
cy.get('.jtk-connector.success').should('have.length', 3);
|
cy.ifCanvasVersion(
|
||||||
cy.get('.data-count').should('have.length', 4);
|
() => cy.get('.jtk-connector.success').should('have.length', 3),
|
||||||
cy.get('.plus-draggable-endpoint').should('have.class', 'ep-success');
|
() => 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.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
WorkflowPage.actions.zoomToFit();
|
WorkflowPage.actions.zoomToFit();
|
||||||
|
|
||||||
cy.get('.plus-draggable-endpoint').filter(':visible').should('not.have.class', 'ep-success');
|
cy.ifCanvasVersion(
|
||||||
cy.get('.jtk-connector.success').should('have.length', 3);
|
() =>
|
||||||
cy.get('.jtk-connector').should('have.length', 4);
|
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', () => {
|
it('should delete node using context menu', () => {
|
||||||
|
@ -194,19 +233,29 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
||||||
WorkflowPage.getters.canvasNodes().should('have.length', 0);
|
WorkflowPage.getters.canvasNodes().should('have.length', 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FIXME: Canvas V2: Figure out how to test moving of the node
|
||||||
it('should move node', () => {
|
it('should move node', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||||
|
|
||||||
WorkflowPage.actions.zoomToFit();
|
WorkflowPage.actions.zoomToFit();
|
||||||
WorkflowPage.getters
|
WorkflowPage.getters
|
||||||
.canvasNodes()
|
.canvasNodes()
|
||||||
.last()
|
.last()
|
||||||
.then(($node) => {
|
.then(($node) => {
|
||||||
const { left, top } = $node.position();
|
const { left, top } = $node.position();
|
||||||
|
|
||||||
|
if (isCanvasV2()) {
|
||||||
|
cy.drag('.vue-flow__node', [300, 300], {
|
||||||
|
realMouse: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], {
|
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], {
|
||||||
clickToFinish: true,
|
clickToFinish: true,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
WorkflowPage.getters
|
WorkflowPage.getters
|
||||||
.canvasNodes()
|
.canvasNodes()
|
||||||
.last()
|
.last()
|
||||||
|
@ -218,80 +267,68 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Canvas Zoom Functionality', () => {
|
||||||
|
const getContainer = () =>
|
||||||
|
cy.ifCanvasVersion(
|
||||||
|
() => WorkflowPage.getters.nodeView(),
|
||||||
|
() => WorkflowPage.getters.canvasViewport(),
|
||||||
|
);
|
||||||
|
const checkZoomLevel = (expectedFactor: number) => {
|
||||||
|
return getContainer().should(($nodeView) => {
|
||||||
|
const newTransform = $nodeView.css('transform');
|
||||||
|
const newScale = parseFloat(newTransform.split(',')[0].slice(7));
|
||||||
|
|
||||||
|
expect(newScale).to.be.closeTo(expectedFactor, 0.2);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomAndCheck = (action: 'zoomIn' | 'zoomOut', expectedFactor: number) => {
|
||||||
|
WorkflowPage.getters[`${action}Button`]().click();
|
||||||
|
checkZoomLevel(expectedFactor);
|
||||||
|
};
|
||||||
|
|
||||||
it('should zoom in', () => {
|
it('should zoom in', () => {
|
||||||
WorkflowPage.getters.zoomInButton().should('be.visible').click();
|
WorkflowPage.getters.zoomInButton().should('be.visible');
|
||||||
WorkflowPage.getters
|
getContainer().then(($nodeView) => {
|
||||||
.nodeView()
|
const initialTransform = $nodeView.css('transform');
|
||||||
.should(
|
const initialScale =
|
||||||
'have.css',
|
initialTransform === 'none' ? 1 : parseFloat(initialTransform.split(',')[0].slice(7));
|
||||||
'transform',
|
|
||||||
`matrix(${ZOOM_IN_X1_FACTOR}, 0, 0, ${ZOOM_IN_X1_FACTOR}, 0, 0)`,
|
zoomAndCheck('zoomIn', initialScale * ZOOM_IN_X1_FACTOR);
|
||||||
);
|
zoomAndCheck('zoomIn', initialScale * ZOOM_IN_X2_FACTOR);
|
||||||
WorkflowPage.getters.zoomInButton().click();
|
});
|
||||||
WorkflowPage.getters
|
|
||||||
.nodeView()
|
|
||||||
.should(
|
|
||||||
'have.css',
|
|
||||||
'transform',
|
|
||||||
`matrix(${ZOOM_IN_X2_FACTOR}, 0, 0, ${ZOOM_IN_X2_FACTOR}, 0, 0)`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should zoom out', () => {
|
it('should zoom out', () => {
|
||||||
WorkflowPage.getters.zoomOutButton().should('be.visible').click();
|
zoomAndCheck('zoomOut', ZOOM_OUT_X1_FACTOR);
|
||||||
WorkflowPage.getters
|
zoomAndCheck('zoomOut', ZOOM_OUT_X2_FACTOR);
|
||||||
.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)`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should zoom using scroll or pinch gesture', () => {
|
it('should zoom using scroll or pinch gesture', () => {
|
||||||
WorkflowPage.actions.pinchToZoom(1, 'zoomIn');
|
WorkflowPage.actions.pinchToZoom(1, 'zoomIn');
|
||||||
WorkflowPage.getters
|
|
||||||
.nodeView()
|
// V2 Canvas is using the same zoom factor for both pinch and scroll
|
||||||
.should(
|
cy.ifCanvasVersion(
|
||||||
'have.css',
|
() => checkZoomLevel(PINCH_ZOOM_IN_FACTOR),
|
||||||
'transform',
|
() => checkZoomLevel(ZOOM_IN_X1_FACTOR),
|
||||||
`matrix(${PINCH_ZOOM_IN_FACTOR}, 0, 0, ${PINCH_ZOOM_IN_FACTOR}, 0, 0)`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
WorkflowPage.actions.pinchToZoom(1, 'zoomOut');
|
WorkflowPage.actions.pinchToZoom(1, 'zoomOut');
|
||||||
// Zoom in 1x + Zoom out 1x should reset to default (=1)
|
checkZoomLevel(1); // 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');
|
WorkflowPage.actions.pinchToZoom(1, 'zoomOut');
|
||||||
WorkflowPage.getters
|
|
||||||
.nodeView()
|
cy.ifCanvasVersion(
|
||||||
.should(
|
() => checkZoomLevel(PINCH_ZOOM_OUT_FACTOR),
|
||||||
'have.css',
|
() => checkZoomLevel(ZOOM_OUT_X1_FACTOR),
|
||||||
'transform',
|
|
||||||
`matrix(${PINCH_ZOOM_OUT_FACTOR}, 0, 0, ${PINCH_ZOOM_OUT_FACTOR}, 0, 0)`,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reset zoom', () => {
|
it('should reset zoom', () => {
|
||||||
// Reset zoom should not appear until zoom level changed
|
|
||||||
WorkflowPage.getters.resetZoomButton().should('not.exist');
|
WorkflowPage.getters.resetZoomButton().should('not.exist');
|
||||||
WorkflowPage.getters.zoomInButton().click();
|
WorkflowPage.getters.zoomInButton().click();
|
||||||
WorkflowPage.getters.resetZoomButton().should('be.visible').click();
|
WorkflowPage.getters.resetZoomButton().should('be.visible').click();
|
||||||
WorkflowPage.getters
|
checkZoomLevel(DEFAULT_ZOOM_FACTOR);
|
||||||
.nodeView()
|
|
||||||
.should(
|
|
||||||
'have.css',
|
|
||||||
'transform',
|
|
||||||
`matrix(${DEFAULT_ZOOM_FACTOR}, 0, 0, ${DEFAULT_ZOOM_FACTOR}, 0, 0)`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should zoom to fit', () => {
|
it('should zoom to fit', () => {
|
||||||
|
@ -304,6 +341,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
||||||
WorkflowPage.getters.zoomToFitButton().click();
|
WorkflowPage.getters.zoomToFitButton().click();
|
||||||
WorkflowPage.getters.canvasNodes().last().should('be.visible');
|
WorkflowPage.getters.canvasNodes().last().should('be.visible');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should disable node (context menu or shortcut)', () => {
|
it('should disable node (context menu or shortcut)', () => {
|
||||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||||
|
@ -426,9 +464,9 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
||||||
cy.reload();
|
cy.reload();
|
||||||
cy.waitForLoad();
|
cy.waitForLoad();
|
||||||
WorkflowPage.getters.canvasNodes().should('have.length', 2);
|
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', () => {
|
it('should remove unknown credentials on pasting workflow', () => {
|
||||||
cy.fixture('workflow-with-unknown-credentials.json').then((data) => {
|
cy.fixture('workflow-with-unknown-credentials.json').then((data) => {
|
||||||
cy.get('body').paste(JSON.stringify(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', () => {
|
it('should render connections correctly if unkown nodes are present', () => {
|
||||||
const unknownNodeName = 'Unknown node';
|
const unknownNodeName = 'Unknown node';
|
||||||
cy.createFixtureWorkflow('workflow-with-unknown-nodes.json', 'Unknown nodes');
|
cy.createFixtureWorkflow('workflow-with-unknown-nodes.json', 'Unknown nodes');
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
import { simpleWebhookCall, waitForWebhook } from './16-webhook-node.cy';
|
||||||
import {
|
import {
|
||||||
HTTP_REQUEST_NODE_NAME,
|
HTTP_REQUEST_NODE_NAME,
|
||||||
MANUAL_TRIGGER_NODE_NAME,
|
MANUAL_TRIGGER_NODE_NAME,
|
||||||
|
@ -7,6 +10,7 @@ import {
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { WorkflowPage, NDV } from '../pages';
|
import { WorkflowPage, NDV } from '../pages';
|
||||||
import { errorToast } from '../pages/notifications';
|
import { errorToast } from '../pages/notifications';
|
||||||
|
import { getVisiblePopper } from '../utils';
|
||||||
|
|
||||||
const workflowPage = new WorkflowPage();
|
const workflowPage = new WorkflowPage();
|
||||||
const ndv = new NDV();
|
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) {
|
function setExpressionOnStringValueInSet(expression: string) {
|
||||||
|
|
|
@ -16,12 +16,14 @@ describe('n8n Form Trigger', () => {
|
||||||
ndv.getters.parameterInput('formDescription').type('Test Form Description');
|
ndv.getters.parameterInput('formDescription').type('Test Form Description');
|
||||||
ndv.getters.parameterInput('fieldLabel').type('Test Field 1');
|
ndv.getters.parameterInput('fieldLabel').type('Test Field 1');
|
||||||
ndv.getters.backToCanvas().click();
|
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', () => {
|
it('should fill up form fields', () => {
|
||||||
workflowPage.actions.addInitialNodeToCanvas('n8n Form Trigger');
|
workflowPage.actions.addInitialNodeToCanvas('n8n Form Trigger', {
|
||||||
workflowPage.getters.canvasNodes().first().dblclick();
|
isTrigger: true,
|
||||||
|
action: 'On new n8n Form event',
|
||||||
|
});
|
||||||
ndv.getters.parameterInput('formTitle').type('Test Form');
|
ndv.getters.parameterInput('formTitle').type('Test Form');
|
||||||
ndv.getters.parameterInput('formDescription').type('Test Form Description');
|
ndv.getters.parameterInput('formDescription').type('Test Form Description');
|
||||||
//fill up first field of type number
|
//fill up first field of type number
|
||||||
|
@ -96,6 +98,6 @@ describe('n8n Form Trigger', () => {
|
||||||
.type('Your test form was successfully submitted');
|
.type('Your test form was successfully submitted');
|
||||||
|
|
||||||
ndv.getters.backToCanvas().click();
|
ndv.getters.backToCanvas().click();
|
||||||
workflowPage.getters.nodeIssuesByName('n8n Form Trigger').should('not.exist');
|
workflowPage.getters.nodeIssuesByName('On form submission').should('not.exist');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,7 +9,7 @@ const workflowPage = new WorkflowPage();
|
||||||
const ndv = new NDV();
|
const ndv = new NDV();
|
||||||
const credentialsModal = new CredentialsModal();
|
const credentialsModal = new CredentialsModal();
|
||||||
|
|
||||||
const waitForWebhook = 500;
|
export const waitForWebhook = 500;
|
||||||
|
|
||||||
interface SimpleWebhookCallOptions {
|
interface SimpleWebhookCallOptions {
|
||||||
method: string;
|
method: string;
|
||||||
|
@ -21,7 +21,7 @@ interface SimpleWebhookCallOptions {
|
||||||
authentication?: string;
|
authentication?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
|
export const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
|
||||||
const {
|
const {
|
||||||
authentication,
|
authentication,
|
||||||
method,
|
method,
|
||||||
|
@ -65,15 +65,23 @@ const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
|
||||||
getVisibleSelect().find('.option-headline').contains(responseData).click();
|
getVisibleSelect().find('.option-headline').contains(responseData).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const callEndpoint = (cb: (response: Cypress.Response<unknown>) => void) => {
|
||||||
|
cy.request(method, `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then(cb);
|
||||||
|
};
|
||||||
|
|
||||||
if (executeNow) {
|
if (executeNow) {
|
||||||
ndv.actions.execute();
|
ndv.actions.execute();
|
||||||
cy.wait(waitForWebhook);
|
cy.wait(waitForWebhook);
|
||||||
|
|
||||||
cy.request(method, `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then((response) => {
|
callEndpoint((response) => {
|
||||||
expect(response.status).to.eq(200);
|
expect(response.status).to.eq(200);
|
||||||
ndv.getters.outputPanel().contains('headers');
|
ndv.getters.outputPanel().contains('headers');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
callEndpoint,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('Webhook Trigger node', () => {
|
describe('Webhook Trigger node', () => {
|
||||||
|
|
|
@ -226,6 +226,7 @@ describe('Sharing', { disableAutoLogin: true }, () => {
|
||||||
.should('have.length', 1)
|
.should('have.length', 1)
|
||||||
.click();
|
.click();
|
||||||
credentialsModal.getters.saveButton().click();
|
credentialsModal.getters.saveButton().click();
|
||||||
|
credentialsModal.getters.saveButton().should('have.text', 'Saved');
|
||||||
credentialsModal.actions.close();
|
credentialsModal.actions.close();
|
||||||
|
|
||||||
projects.getProjectTabWorkflows().click();
|
projects.getProjectTabWorkflows().click();
|
||||||
|
@ -252,12 +253,13 @@ describe('Sharing', { disableAutoLogin: true }, () => {
|
||||||
credentialsModal.getters.usersSelect().click();
|
credentialsModal.getters.usersSelect().click();
|
||||||
getVisibleSelect().find('li').should('have.length', 4).first().click();
|
getVisibleSelect().find('li').should('have.length', 4).first().click();
|
||||||
credentialsModal.getters.saveButton().click();
|
credentialsModal.getters.saveButton().click();
|
||||||
|
credentialsModal.getters.saveButton().should('have.text', 'Saved');
|
||||||
credentialsModal.actions.close();
|
credentialsModal.actions.close();
|
||||||
|
|
||||||
credentialsPage.getters
|
credentialsPage.getters
|
||||||
.credentialCards()
|
.credentialCards()
|
||||||
.should('have.length', 2)
|
.should('have.length', 2)
|
||||||
.filter(':contains("Owned by me")')
|
.filter(':contains("Personal")')
|
||||||
.should('have.length', 1);
|
.should('have.length', 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { SCHEDULE_TRIGGER_NODE_NAME, EDIT_FIELDS_SET_NODE_NAME } from '../constants';
|
import { SCHEDULE_TRIGGER_NODE_NAME, EDIT_FIELDS_SET_NODE_NAME } from '../constants';
|
||||||
import { NDV, WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
|
import { NDV, WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
|
||||||
import { clearNotifications, errorToast, successToast } from '../pages/notifications';
|
import { clearNotifications, errorToast, successToast } from '../pages/notifications';
|
||||||
|
import { isCanvasV2 } from '../utils/workflowUtils';
|
||||||
|
|
||||||
const workflowPage = new WorkflowPageClass();
|
const workflowPage = new WorkflowPageClass();
|
||||||
const executionsTab = new WorkflowExecutionsTab();
|
const executionsTab = new WorkflowExecutionsTab();
|
||||||
|
@ -117,15 +118,22 @@ describe('Execution', () => {
|
||||||
.canvasNodeByName('Manual')
|
.canvasNodeByName('Manual')
|
||||||
.within(() => cy.get('.fa-check'))
|
.within(() => cy.get('.fa-check'))
|
||||||
.should('exist');
|
.should('exist');
|
||||||
|
|
||||||
|
if (isCanvasV2()) {
|
||||||
workflowPage.getters
|
workflowPage.getters
|
||||||
.canvasNodeByName('Wait')
|
.canvasNodeByName('Wait')
|
||||||
.within(() => cy.get('.fa-sync-alt').should('not.visible'));
|
.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
|
workflowPage.getters
|
||||||
.canvasNodeByName('Set')
|
.canvasNodeByName('Set')
|
||||||
.within(() => cy.get('.fa-check').should('not.exist'));
|
.within(() => cy.get('.fa-check').should('not.exist'));
|
||||||
|
|
||||||
successToast().should('be.visible');
|
successToast().should('be.visible');
|
||||||
clearNotifications();
|
|
||||||
|
|
||||||
// Clear execution data
|
// Clear execution data
|
||||||
workflowPage.getters.clearExecutionDataButton().should('be.visible');
|
workflowPage.getters.clearExecutionDataButton().should('be.visible');
|
||||||
|
@ -206,6 +214,7 @@ describe('Execution', () => {
|
||||||
workflowPage.getters.clearExecutionDataButton().should('not.exist');
|
workflowPage.getters.clearExecutionDataButton().should('not.exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FIXME: Canvas V2: Webhook should show waiting state but it doesn't
|
||||||
it('should test webhook workflow stop', () => {
|
it('should test webhook workflow stop', () => {
|
||||||
cy.createFixtureWorkflow('Webhook_wait_set.json');
|
cy.createFixtureWorkflow('Webhook_wait_set.json');
|
||||||
|
|
||||||
|
@ -267,9 +276,17 @@ describe('Execution', () => {
|
||||||
.canvasNodeByName('Webhook')
|
.canvasNodeByName('Webhook')
|
||||||
.within(() => cy.get('.fa-check'))
|
.within(() => cy.get('.fa-check'))
|
||||||
.should('exist');
|
.should('exist');
|
||||||
|
|
||||||
|
if (isCanvasV2()) {
|
||||||
workflowPage.getters
|
workflowPage.getters
|
||||||
.canvasNodeByName('Wait')
|
.canvasNodeByName('Wait')
|
||||||
.within(() => cy.get('.fa-sync-alt').should('not.visible'));
|
.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
|
workflowPage.getters
|
||||||
.canvasNodeByName('Set')
|
.canvasNodeByName('Set')
|
||||||
.within(() => cy.get('.fa-check').should('not.exist'));
|
.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', () => {
|
describe('connections should be colored differently for pinned data', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
cy.createFixtureWorkflow('Schedule_pinned.json');
|
cy.createFixtureWorkflow('Schedule_pinned.json');
|
||||||
|
|
|
@ -15,7 +15,7 @@ import {
|
||||||
TRELLO_NODE_NAME,
|
TRELLO_NODE_NAME,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { CredentialsModal, CredentialsPage, NDV, WorkflowPage } from '../pages';
|
import { CredentialsModal, CredentialsPage, NDV, WorkflowPage } from '../pages';
|
||||||
import { successToast } from '../pages/notifications';
|
import { errorToast, successToast } from '../pages/notifications';
|
||||||
import { getVisibleSelect } from '../utils';
|
import { getVisibleSelect } from '../utils';
|
||||||
|
|
||||||
const credentialsPage = new CredentialsPage();
|
const credentialsPage = new CredentialsPage();
|
||||||
|
@ -278,4 +278,25 @@ describe('Credentials', () => {
|
||||||
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
|
credentialsModal.getters.credentialAuthTypeRadioButtons().first().click();
|
||||||
nodeDetailsView.getters.copyInput().should('not.exist');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
86
cypress/e2e/2372-ado-prevent-clipping-params.cy.ts
Normal file
86
cypress/e2e/2372-ado-prevent-clipping-params.cy.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
|
@ -117,7 +117,8 @@ describe('Debug', () => {
|
||||||
workflowPage.getters.canvasNodes().last().find('.node-info-icon').should('be.empty');
|
workflowPage.getters.canvasNodes().last().find('.node-info-icon').should('be.empty');
|
||||||
|
|
||||||
workflowPage.getters.canvasNodes().first().dblclick();
|
workflowPage.getters.canvasNodes().first().dblclick();
|
||||||
ndv.getters.pinDataButton().click();
|
ndv.actions.unPinData();
|
||||||
|
|
||||||
ndv.actions.close();
|
ndv.actions.close();
|
||||||
|
|
||||||
workflowPage.actions.saveWorkflowUsingKeyboardShortcut();
|
workflowPage.actions.saveWorkflowUsingKeyboardShortcut();
|
||||||
|
|
|
@ -15,6 +15,7 @@ import {
|
||||||
NDV,
|
NDV,
|
||||||
MainSidebar,
|
MainSidebar,
|
||||||
} from '../pages';
|
} from '../pages';
|
||||||
|
import { clearNotifications } from '../pages/notifications';
|
||||||
import { getVisibleDropdown, getVisibleModalOverlay, getVisibleSelect } from '../utils';
|
import { getVisibleDropdown, getVisibleModalOverlay, getVisibleSelect } from '../utils';
|
||||||
|
|
||||||
const workflowsPage = new WorkflowsPage();
|
const workflowsPage = new WorkflowsPage();
|
||||||
|
@ -448,38 +449,48 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
workflowsPage.getters.workflowCards().should('not.have.length');
|
workflowsPage.getters.workflowCards().should('not.have.length');
|
||||||
workflowsPage.getters.newWorkflowButtonCard().click();
|
workflowsPage.getters.newWorkflowButtonCard().click();
|
||||||
projects.createWorkflow('Test_workflow_1.json', 'Workflow in Home project');
|
projects.createWorkflow('Test_workflow_1.json', 'Workflow in Home project');
|
||||||
|
clearNotifications();
|
||||||
|
|
||||||
projects.getHomeButton().click();
|
projects.getHomeButton().click();
|
||||||
projects.getProjectTabCredentials().should('be.visible').click();
|
projects.getProjectTabCredentials().should('be.visible').click();
|
||||||
credentialsPage.getters.emptyListCreateCredentialButton().click();
|
credentialsPage.getters.emptyListCreateCredentialButton().click();
|
||||||
projects.createCredential('Credential in Home project');
|
projects.createCredential('Credential in Home project');
|
||||||
|
|
||||||
|
clearNotifications();
|
||||||
|
|
||||||
// Create a project and add a credential and a workflow to it
|
// Create a project and add a credential and a workflow to it
|
||||||
projects.createProject('Project 1');
|
projects.createProject('Project 1');
|
||||||
|
clearNotifications();
|
||||||
projects.getProjectTabCredentials().click();
|
projects.getProjectTabCredentials().click();
|
||||||
credentialsPage.getters.emptyListCreateCredentialButton().click();
|
credentialsPage.getters.emptyListCreateCredentialButton().click();
|
||||||
projects.createCredential('Credential in Project 1');
|
projects.createCredential('Credential in Project 1');
|
||||||
|
clearNotifications();
|
||||||
|
|
||||||
projects.getProjectTabWorkflows().click();
|
projects.getProjectTabWorkflows().click();
|
||||||
workflowsPage.getters.newWorkflowButtonCard().click();
|
workflowsPage.getters.newWorkflowButtonCard().click();
|
||||||
projects.createWorkflow('Test_workflow_1.json', 'Workflow in Project 1');
|
projects.createWorkflow('Test_workflow_1.json', 'Workflow in Project 1');
|
||||||
|
|
||||||
|
clearNotifications();
|
||||||
|
|
||||||
// Create another project and add a credential and a workflow to it
|
// Create another project and add a credential and a workflow to it
|
||||||
projects.createProject('Project 2');
|
projects.createProject('Project 2');
|
||||||
|
clearNotifications();
|
||||||
projects.getProjectTabCredentials().click();
|
projects.getProjectTabCredentials().click();
|
||||||
credentialsPage.getters.emptyListCreateCredentialButton().click();
|
credentialsPage.getters.emptyListCreateCredentialButton().click();
|
||||||
projects.createCredential('Credential in Project 2');
|
projects.createCredential('Credential in Project 2');
|
||||||
|
clearNotifications();
|
||||||
|
|
||||||
projects.getProjectTabWorkflows().click();
|
projects.getProjectTabWorkflows().click();
|
||||||
workflowsPage.getters.newWorkflowButtonCard().click();
|
workflowsPage.getters.newWorkflowButtonCard().click();
|
||||||
projects.createWorkflow('Test_workflow_1.json', 'Workflow in Project 2');
|
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();
|
projects.getHomeButton().click();
|
||||||
workflowsPage.getters
|
workflowsPage.getters
|
||||||
.workflowCards()
|
.workflowCards()
|
||||||
.should('have.length', 3)
|
.should('have.length', 3)
|
||||||
.filter(':contains("Owned by me")')
|
.filter(':contains("Personal")')
|
||||||
.should('exist');
|
.should('exist');
|
||||||
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
|
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
|
||||||
workflowsPage.getters.workflowMoveButton().click();
|
workflowsPage.getters.workflowMoveButton().click();
|
||||||
|
@ -496,11 +507,12 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
.filter(':contains("Project 1")')
|
.filter(':contains("Project 1")')
|
||||||
.click();
|
.click();
|
||||||
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
|
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
|
||||||
|
clearNotifications();
|
||||||
|
|
||||||
workflowsPage.getters
|
workflowsPage.getters
|
||||||
.workflowCards()
|
.workflowCards()
|
||||||
.should('have.length', 3)
|
.should('have.length', 3)
|
||||||
.filter(':contains("Owned by me")')
|
.filter(':contains("Personal")')
|
||||||
.should('not.exist');
|
.should('not.exist');
|
||||||
|
|
||||||
// Move the workflow from Project 1 to Project 2
|
// Move the workflow from Project 1 to Project 2
|
||||||
|
@ -527,6 +539,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
workflowsPage.getters.workflowCards().should('have.length', 2);
|
workflowsPage.getters.workflowCards().should('have.length', 2);
|
||||||
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
|
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
|
||||||
workflowsPage.getters.workflowMoveButton().click();
|
workflowsPage.getters.workflowMoveButton().click();
|
||||||
|
clearNotifications();
|
||||||
|
|
||||||
projects
|
projects
|
||||||
.getResourceMoveModal()
|
.getResourceMoveModal()
|
||||||
|
@ -566,10 +579,11 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
|
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
|
||||||
|
clearNotifications();
|
||||||
workflowsPage.getters
|
workflowsPage.getters
|
||||||
.workflowCards()
|
.workflowCards()
|
||||||
.should('have.length', 3)
|
.should('have.length', 3)
|
||||||
.filter(':contains("Owned by me")')
|
.filter(':contains("Personal")')
|
||||||
.should('have.length', 1);
|
.should('have.length', 1);
|
||||||
|
|
||||||
// Move the credential from Project 1 to Project 2
|
// Move the credential from Project 1 to Project 2
|
||||||
|
@ -591,7 +605,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
.filter(':contains("Project 2")')
|
.filter(':contains("Project 2")')
|
||||||
.click();
|
.click();
|
||||||
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
|
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
|
||||||
|
clearNotifications();
|
||||||
credentialsPage.getters.credentialCards().should('not.have.length');
|
credentialsPage.getters.credentialCards().should('not.have.length');
|
||||||
|
|
||||||
// Move the credential from Project 2 to admin user
|
// Move the credential from Project 2 to admin user
|
||||||
|
@ -637,10 +651,12 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
.click();
|
.click();
|
||||||
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
|
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
|
||||||
|
|
||||||
|
clearNotifications();
|
||||||
|
|
||||||
credentialsPage.getters
|
credentialsPage.getters
|
||||||
.credentialCards()
|
.credentialCards()
|
||||||
.should('have.length', 3)
|
.should('have.length', 3)
|
||||||
.filter(':contains("Owned by me")')
|
.filter(':contains("Personal")')
|
||||||
.should('have.length', 2);
|
.should('have.length', 2);
|
||||||
|
|
||||||
// Move the credential from admin user back to its original project (Project 1)
|
// Move the credential from admin user back to its original project (Project 1)
|
||||||
|
@ -699,7 +715,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
workflowsPage.getters
|
workflowsPage.getters
|
||||||
.workflowCards()
|
.workflowCards()
|
||||||
.should('have.length', 1)
|
.should('have.length', 1)
|
||||||
.filter(':contains("Owned by me")')
|
.filter(':contains("Personal")')
|
||||||
.should('exist');
|
.should('exist');
|
||||||
workflowsPage.getters.workflowCardActions('My workflow').click();
|
workflowsPage.getters.workflowCardActions('My workflow').click();
|
||||||
workflowsPage.getters.workflowMoveButton().click();
|
workflowsPage.getters.workflowMoveButton().click();
|
||||||
|
@ -720,7 +736,7 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
||||||
workflowsPage.getters
|
workflowsPage.getters
|
||||||
.workflowCards()
|
.workflowCards()
|
||||||
.should('have.length', 1)
|
.should('have.length', 1)
|
||||||
.filter(':contains("Owned by me")')
|
.filter(':contains("Personal")')
|
||||||
.should('not.exist');
|
.should('not.exist');
|
||||||
|
|
||||||
//Log out with instance owner and log in with the member user
|
//Log out with instance owner and log in with the member user
|
||||||
|
|
|
@ -23,6 +23,7 @@ describe('Manual partial execution', () => {
|
||||||
canvas.actions.openNode('Webhook1');
|
canvas.actions.openNode('Webhook1');
|
||||||
|
|
||||||
ndv.getters.nodeRunSuccessIndicator().should('exist');
|
ndv.getters.nodeRunSuccessIndicator().should('exist');
|
||||||
|
ndv.getters.nodeRunTooltipIndicator().should('exist');
|
||||||
ndv.getters.outputRunSelector().should('not.exist'); // single run
|
ndv.getters.outputRunSelector().should('not.exist'); // single run
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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.",
|
"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.nodeRunErrorIndicator().should('be.visible');
|
||||||
|
ndv.getters.nodeRunTooltipIndicator().should('be.visible');
|
||||||
// The error details should be hidden behind a tooltip
|
// The error details should be hidden behind a tooltip
|
||||||
ndv.getters.nodeRunErrorIndicator().should('not.contain', 'Start Time');
|
ndv.getters.nodeRunTooltipIndicator().should('not.contain', 'Start Time');
|
||||||
ndv.getters.nodeRunErrorIndicator().should('not.contain', 'Execution Time');
|
ndv.getters.nodeRunTooltipIndicator().should('not.contain', 'Execution Time');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should save workflow using keyboard shortcut from NDV', () => {
|
it('should save workflow using keyboard shortcut from NDV', () => {
|
||||||
|
@ -617,8 +618,10 @@ describe('NDV', () => {
|
||||||
// Should not show run info before execution
|
// Should not show run info before execution
|
||||||
ndv.getters.nodeRunSuccessIndicator().should('not.exist');
|
ndv.getters.nodeRunSuccessIndicator().should('not.exist');
|
||||||
ndv.getters.nodeRunErrorIndicator().should('not.exist');
|
ndv.getters.nodeRunErrorIndicator().should('not.exist');
|
||||||
|
ndv.getters.nodeRunTooltipIndicator().should('not.exist');
|
||||||
ndv.getters.nodeExecuteButton().click();
|
ndv.getters.nodeExecuteButton().click();
|
||||||
ndv.getters.nodeRunSuccessIndicator().should('exist');
|
ndv.getters.nodeRunSuccessIndicator().should('exist');
|
||||||
|
ndv.getters.nodeRunTooltipIndicator().should('exist');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should properly show node execution indicator for multiple nodes', () => {
|
it('should properly show node execution indicator for multiple nodes', () => {
|
||||||
|
@ -630,6 +633,7 @@ describe('NDV', () => {
|
||||||
// Manual tigger node should show success indicator
|
// Manual tigger node should show success indicator
|
||||||
workflowPage.actions.openNode('When clicking ‘Test workflow’');
|
workflowPage.actions.openNode('When clicking ‘Test workflow’');
|
||||||
ndv.getters.nodeRunSuccessIndicator().should('exist');
|
ndv.getters.nodeRunSuccessIndicator().should('exist');
|
||||||
|
ndv.getters.nodeRunTooltipIndicator().should('exist');
|
||||||
// Code node should show error
|
// Code node should show error
|
||||||
ndv.getters.backToCanvas().click();
|
ndv.getters.backToCanvas().click();
|
||||||
workflowPage.actions.openNode('Code');
|
workflowPage.actions.openNode('Code');
|
||||||
|
|
|
@ -162,13 +162,6 @@ return []
|
||||||
cy.get('#tab-code').should('have.class', 'is-active');
|
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();
|
|
||||||
|
|
||||||
cy.getByTestId('ask-ai-prompt-input').type(prompt);
|
|
||||||
|
|
||||||
const handledCodes = [
|
const handledCodes = [
|
||||||
{ code: 400, message: 'Code generation failed due to an unknown reason' },
|
{ 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: 413, message: 'Your workflow data is too large for AI to process' },
|
||||||
|
@ -177,6 +170,13 @@ return []
|
||||||
];
|
];
|
||||||
|
|
||||||
handledCodes.forEach(({ code, message }) => {
|
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();
|
||||||
|
|
||||||
|
cy.getByTestId('ask-ai-prompt-input').type(prompt);
|
||||||
|
|
||||||
cy.intercept('POST', '/rest/ai/ask-ai', {
|
cy.intercept('POST', '/rest/ai/ask-ai', {
|
||||||
statusCode: code,
|
statusCode: code,
|
||||||
status: code,
|
status: code,
|
||||||
|
|
39
cypress/fixtures/Pinned_webhook_node.json
Normal file
39
cypress/fixtures/Pinned_webhook_node.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
150
cypress/fixtures/Test-workflow-with-long-parameters.json
Normal file
150
cypress/fixtures/Test-workflow-with-long-parameters.json
Normal 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": {}
|
||||||
|
}
|
|
@ -20,7 +20,8 @@ export class NDV extends BasePage {
|
||||||
outputDataContainer: () => this.getters.outputPanel().findChildByTestId('ndv-data-container'),
|
outputDataContainer: () => this.getters.outputPanel().findChildByTestId('ndv-data-container'),
|
||||||
outputDisplayMode: () =>
|
outputDisplayMode: () =>
|
||||||
this.getters.outputPanel().findChildByTestId('ndv-run-data-display-mode').first(),
|
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'),
|
editPinnedDataButton: () => cy.getByTestId('ndv-edit-pinned-data'),
|
||||||
pinnedDataEditor: () => this.getters.outputPanel().find('.cm-editor .cm-scroller .cm-content'),
|
pinnedDataEditor: () => this.getters.outputPanel().find('.cm-editor .cm-scroller .cm-content'),
|
||||||
runDataPaneHeader: () => cy.getByTestId('run-data-pane-header'),
|
runDataPaneHeader: () => cy.getByTestId('run-data-pane-header'),
|
||||||
|
@ -63,6 +64,7 @@ export class NDV extends BasePage {
|
||||||
nodeRenameInput: () => cy.getByTestId('node-rename-input'),
|
nodeRenameInput: () => cy.getByTestId('node-rename-input'),
|
||||||
executePrevious: () => cy.getByTestId('execute-previous-node'),
|
executePrevious: () => cy.getByTestId('execute-previous-node'),
|
||||||
httpRequestNotice: () => cy.getByTestId('node-parameters-http-notice'),
|
httpRequestNotice: () => cy.getByTestId('node-parameters-http-notice'),
|
||||||
|
nodeCredentialsLabel: () => cy.getByTestId('credentials-label'),
|
||||||
nthParam: (n: number) => cy.getByTestId('node-parameters').find('.parameter-item').eq(n),
|
nthParam: (n: number) => cy.getByTestId('node-parameters').find('.parameter-item').eq(n),
|
||||||
inputRunSelector: () => this.getters.inputPanel().findChildByTestId('run-selector'),
|
inputRunSelector: () => this.getters.inputPanel().findChildByTestId('run-selector'),
|
||||||
inputLinkRun: () => this.getters.inputPanel().findChildByTestId('link-run'),
|
inputLinkRun: () => this.getters.inputPanel().findChildByTestId('link-run'),
|
||||||
|
@ -130,8 +132,9 @@ export class NDV extends BasePage {
|
||||||
codeEditorFullscreenButton: () => cy.getByTestId('code-editor-fullscreen-button'),
|
codeEditorFullscreenButton: () => cy.getByTestId('code-editor-fullscreen-button'),
|
||||||
codeEditorDialog: () => cy.getByTestId('code-editor-fullscreen'),
|
codeEditorDialog: () => cy.getByTestId('code-editor-fullscreen'),
|
||||||
codeEditorFullscreen: () => this.getters.codeEditorDialog().find('.cm-content'),
|
codeEditorFullscreen: () => this.getters.codeEditorDialog().find('.cm-content'),
|
||||||
nodeRunSuccessIndicator: () => cy.getByTestId('node-run-info-success'),
|
nodeRunTooltipIndicator: () => cy.getByTestId('node-run-info'),
|
||||||
nodeRunErrorIndicator: () => cy.getByTestId('node-run-info-danger'),
|
nodeRunSuccessIndicator: () => cy.getByTestId('node-run-status-success'),
|
||||||
|
nodeRunErrorIndicator: () => cy.getByTestId('node-run-status-danger'),
|
||||||
nodeRunErrorMessage: () => cy.getByTestId('node-error-message'),
|
nodeRunErrorMessage: () => cy.getByTestId('node-error-message'),
|
||||||
nodeRunErrorDescription: () => cy.getByTestId('node-error-description'),
|
nodeRunErrorDescription: () => cy.getByTestId('node-error-description'),
|
||||||
fixedCollectionParameter: (paramName: string) =>
|
fixedCollectionParameter: (paramName: string) =>
|
||||||
|
@ -146,6 +149,9 @@ export class NDV extends BasePage {
|
||||||
pinData: () => {
|
pinData: () => {
|
||||||
this.getters.pinDataButton().click({ force: true });
|
this.getters.pinDataButton().click({ force: true });
|
||||||
},
|
},
|
||||||
|
unPinData: () => {
|
||||||
|
this.getters.unpinDataLink().click({ force: true });
|
||||||
|
},
|
||||||
editPinnedData: () => {
|
editPinnedData: () => {
|
||||||
this.getters.editPinnedDataButton().click();
|
this.getters.editPinnedDataButton().click();
|
||||||
},
|
},
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { BasePage } from './base';
|
||||||
import { NodeCreator } from './features/node-creator';
|
import { NodeCreator } from './features/node-creator';
|
||||||
import { META_KEY } from '../constants';
|
import { META_KEY } from '../constants';
|
||||||
import { getVisibleSelect } from '../utils';
|
import { getVisibleSelect } from '../utils';
|
||||||
import { getUniqueWorkflowName } from '../utils/workflowUtils';
|
import { getUniqueWorkflowName, isCanvasV2 } from '../utils/workflowUtils';
|
||||||
|
|
||||||
const nodeCreator = new NodeCreator();
|
const nodeCreator = new NodeCreator();
|
||||||
export class WorkflowPage extends BasePage {
|
export class WorkflowPage extends BasePage {
|
||||||
|
@ -27,7 +27,11 @@ export class WorkflowPage extends BasePage {
|
||||||
nodeCreatorSearchBar: () => cy.getByTestId('node-creator-search-bar'),
|
nodeCreatorSearchBar: () => cy.getByTestId('node-creator-search-bar'),
|
||||||
nodeCreatorPlusButton: () => cy.getByTestId('node-creator-plus-button'),
|
nodeCreatorPlusButton: () => cy.getByTestId('node-creator-plus-button'),
|
||||||
canvasPlusButton: () => cy.getByTestId('canvas-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) =>
|
canvasNodeByName: (nodeName: string) =>
|
||||||
this.getters.canvasNodes().filter(`:contains(${nodeName})`),
|
this.getters.canvasNodes().filter(`:contains(${nodeName})`),
|
||||||
nodeIssuesByName: (nodeName: string) =>
|
nodeIssuesByName: (nodeName: string) =>
|
||||||
|
@ -37,6 +41,17 @@ export class WorkflowPage extends BasePage {
|
||||||
.should('have.length.greaterThan', 0)
|
.should('have.length.greaterThan', 0)
|
||||||
.findChildByTestId('node-issues'),
|
.findChildByTestId('node-issues'),
|
||||||
getEndpointSelector: (type: 'input' | 'output' | 'plus', nodeName: string, index = 0) => {
|
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}']`;
|
return `[data-endpoint-name='${nodeName}'][data-endpoint-type='${type}'][data-input-index='${index}']`;
|
||||||
},
|
},
|
||||||
canvasNodeInputEndpointByName: (nodeName: string, index = 0) => {
|
canvasNodeInputEndpointByName: (nodeName: string, index = 0) => {
|
||||||
|
@ -46,7 +61,15 @@ export class WorkflowPage extends BasePage {
|
||||||
return cy.get(this.getters.getEndpointSelector('output', nodeName, index));
|
return cy.get(this.getters.getEndpointSelector('output', nodeName, index));
|
||||||
},
|
},
|
||||||
canvasNodePlusEndpointByName: (nodeName: string, index = 0) => {
|
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'),
|
activatorSwitch: () => cy.getByTestId('workflow-activate-switch'),
|
||||||
workflowMenu: () => cy.getByTestId('workflow-menu'),
|
workflowMenu: () => cy.getByTestId('workflow-menu'),
|
||||||
|
@ -56,13 +79,29 @@ export class WorkflowPage extends BasePage {
|
||||||
expressionModalInput: () => cy.getByTestId('expression-modal-input').find('[role=textbox]'),
|
expressionModalInput: () => cy.getByTestId('expression-modal-input').find('[role=textbox]'),
|
||||||
expressionModalOutput: () => cy.getByTestId('expression-modal-output'),
|
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'),
|
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'),
|
zoomToFitButton: () => cy.getByTestId('zoom-to-fit'),
|
||||||
nodeEndpoints: () => cy.get('.jtk-endpoint-connected'),
|
nodeEndpoints: () => cy.get('.jtk-endpoint-connected'),
|
||||||
disabledNodes: () => cy.get('.node-box.disabled'),
|
disabledNodes: () =>
|
||||||
selectedNodes: () => this.getters.canvasNodes().filter('.jtk-drag-selected'),
|
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
|
// Workflow menu items
|
||||||
workflowMenuItemDuplicate: () => cy.getByTestId('workflow-menu-item-duplicate'),
|
workflowMenuItemDuplicate: () => cy.getByTestId('workflow-menu-item-duplicate'),
|
||||||
workflowMenuItemDownload: () => cy.getByTestId('workflow-menu-item-download'),
|
workflowMenuItemDownload: () => cy.getByTestId('workflow-menu-item-download'),
|
||||||
|
@ -92,8 +131,21 @@ export class WorkflowPage extends BasePage {
|
||||||
shareButton: () => cy.getByTestId('workflow-share-button'),
|
shareButton: () => cy.getByTestId('workflow-share-button'),
|
||||||
|
|
||||||
duplicateWorkflowModal: () => cy.getByTestId('duplicate-modal'),
|
duplicateWorkflowModal: () => cy.getByTestId('duplicate-modal'),
|
||||||
nodeViewBackground: () => cy.getByTestId('node-view-background'),
|
nodeViewBackground: () =>
|
||||||
nodeView: () => cy.getByTestId('node-view'),
|
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: () =>
|
inlineExpressionEditorInput: () =>
|
||||||
cy.getByTestId('inline-expression-editor-input').find('[role=textbox]'),
|
cy.getByTestId('inline-expression-editor-input').find('[role=textbox]'),
|
||||||
inlineExpressionEditorOutput: () => cy.getByTestId('inline-expression-editor-output'),
|
inlineExpressionEditorOutput: () => cy.getByTestId('inline-expression-editor-output'),
|
||||||
|
@ -115,19 +167,45 @@ export class WorkflowPage extends BasePage {
|
||||||
ndvParameters: () => cy.getByTestId('parameter-item'),
|
ndvParameters: () => cy.getByTestId('parameter-item'),
|
||||||
nodeCredentialsLabel: () => cy.getByTestId('credentials-label'),
|
nodeCredentialsLabel: () => cy.getByTestId('credentials-label'),
|
||||||
getConnectionBetweenNodes: (sourceNodeName: string, targetNodeName: string) =>
|
getConnectionBetweenNodes: (sourceNodeName: string, targetNodeName: string) =>
|
||||||
|
cy.ifCanvasVersion(
|
||||||
|
() =>
|
||||||
cy.get(
|
cy.get(
|
||||||
`.jtk-connector[data-source-node="${sourceNodeName}"][data-target-node="${targetNodeName}"]`,
|
`.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) =>
|
getConnectionActionsBetweenNodes: (sourceNodeName: string, targetNodeName: string) =>
|
||||||
|
cy.ifCanvasVersion(
|
||||||
|
() =>
|
||||||
cy.get(
|
cy.get(
|
||||||
`.connection-actions[data-source-node="${sourceNodeName}"][data-target-node="${targetNodeName}"]`,
|
`.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'),
|
addStickyButton: () => cy.getByTestId('add-sticky-button'),
|
||||||
stickies: () => cy.getByTestId('sticky'),
|
stickies: () => cy.getByTestId('sticky'),
|
||||||
editorTabButton: () => cy.getByTestId('radio-button-workflow'),
|
editorTabButton: () => cy.getByTestId('radio-button-workflow'),
|
||||||
workflowHistoryButton: () => cy.getByTestId('workflow-history-button'),
|
workflowHistoryButton: () => cy.getByTestId('workflow-history-button'),
|
||||||
colors: () => cy.getByTestId('color'),
|
colors: () => cy.getByTestId('color'),
|
||||||
contextMenuAction: (action: string) => cy.getByTestId(`context-menu-item-${action}`),
|
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 = {
|
actions = {
|
||||||
|
@ -332,7 +410,7 @@ export class WorkflowPage extends BasePage {
|
||||||
pinchToZoom: (steps: number, mode: 'zoomIn' | 'zoomOut' = 'zoomIn') => {
|
pinchToZoom: (steps: number, mode: 'zoomIn' | 'zoomOut' = 'zoomIn') => {
|
||||||
cy.window().then((win) => {
|
cy.window().then((win) => {
|
||||||
// Pinch-to-zoom simulates a 'wheel' event with ctrlKey: true (same as zooming by scrolling)
|
// 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,
|
force: true,
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
ctrlKey: true,
|
ctrlKey: true,
|
||||||
|
@ -391,9 +469,12 @@ export class WorkflowPage extends BasePage {
|
||||||
action?: string,
|
action?: string,
|
||||||
) => {
|
) => {
|
||||||
this.getters.getConnectionBetweenNodes(sourceNodeName, targetNodeName).first().realHover();
|
this.getters.getConnectionBetweenNodes(sourceNodeName, targetNodeName).first().realHover();
|
||||||
this.getters
|
const connectionsBetweenNodes = () =>
|
||||||
.getConnectionActionsBetweenNodes(sourceNodeName, targetNodeName)
|
this.getters.getConnectionActionsBetweenNodes(sourceNodeName, targetNodeName);
|
||||||
.find('.add')
|
cy.ifCanvasVersion(
|
||||||
|
() => connectionsBetweenNodes().find('.add'),
|
||||||
|
() => connectionsBetweenNodes().get('[data-test-id="add-connection-button"]'),
|
||||||
|
)
|
||||||
.first()
|
.first()
|
||||||
.click({ force: true });
|
.click({ force: true });
|
||||||
|
|
||||||
|
@ -401,9 +482,12 @@ export class WorkflowPage extends BasePage {
|
||||||
},
|
},
|
||||||
deleteNodeBetweenNodes: (sourceNodeName: string, targetNodeName: string) => {
|
deleteNodeBetweenNodes: (sourceNodeName: string, targetNodeName: string) => {
|
||||||
this.getters.getConnectionBetweenNodes(sourceNodeName, targetNodeName).first().realHover();
|
this.getters.getConnectionBetweenNodes(sourceNodeName, targetNodeName).first().realHover();
|
||||||
this.getters
|
const connectionsBetweenNodes = () =>
|
||||||
.getConnectionActionsBetweenNodes(sourceNodeName, targetNodeName)
|
this.getters.getConnectionActionsBetweenNodes(sourceNodeName, targetNodeName);
|
||||||
.find('.delete')
|
cy.ifCanvasVersion(
|
||||||
|
() => connectionsBetweenNodes().find('.delete'),
|
||||||
|
() => connectionsBetweenNodes().get('[data-test-id="delete-connection-button"]'),
|
||||||
|
)
|
||||||
.first()
|
.first()
|
||||||
.click({ force: true });
|
.click({ force: true });
|
||||||
},
|
},
|
||||||
|
|
|
@ -10,7 +10,7 @@ import {
|
||||||
N8N_AUTH_COOKIE,
|
N8N_AUTH_COOKIE,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { WorkflowPage } from '../pages';
|
import { WorkflowPage } from '../pages';
|
||||||
import { getUniqueWorkflowName } from '../utils/workflowUtils';
|
import { getUniqueWorkflowName, isCanvasV2 } from '../utils/workflowUtils';
|
||||||
|
|
||||||
Cypress.Commands.add('setAppDate', (targetDate: number | Date) => {
|
Cypress.Commands.add('setAppDate', (targetDate: number | Date) => {
|
||||||
cy.window().then((win) => {
|
cy.window().then((win) => {
|
||||||
|
@ -26,6 +26,10 @@ Cypress.Commands.add('getByTestId', (selector, ...args) => {
|
||||||
return cy.get(`[data-test-id="${selector}"]`, ...args);
|
return cy.get(`[data-test-id="${selector}"]`, ...args);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add('ifCanvasVersion', (getterV1, getterV2) => {
|
||||||
|
return isCanvasV2() ? getterV2() : getterV1();
|
||||||
|
});
|
||||||
|
|
||||||
Cypress.Commands.add(
|
Cypress.Commands.add(
|
||||||
'createFixtureWorkflow',
|
'createFixtureWorkflow',
|
||||||
(fixtureKey: string, workflowName = getUniqueWorkflowName()) => {
|
(fixtureKey: string, workflowName = getUniqueWorkflowName()) => {
|
||||||
|
@ -70,6 +74,10 @@ Cypress.Commands.add('signin', ({ email, password }) => {
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
Cypress.env('currentUserId', response.body.data.id);
|
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
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -20,6 +20,11 @@ beforeEach(() => {
|
||||||
win.localStorage.setItem('N8N_THEME', 'light');
|
win.localStorage.setItem('N8N_THEME', 'light');
|
||||||
win.localStorage.setItem('N8N_AUTOCOMPLETE_ONBOARDED', 'true');
|
win.localStorage.setItem('N8N_AUTOCOMPLETE_ONBOARDED', 'true');
|
||||||
win.localStorage.setItem('N8N_MAPPING_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) => {
|
cy.intercept('GET', '/rest/settings', (req) => {
|
||||||
|
|
|
@ -28,6 +28,7 @@ declare global {
|
||||||
selector: string,
|
selector: string,
|
||||||
...args: Array<Partial<Loggable & Timeoutable & Withinable & Shadow> | undefined>
|
...args: Array<Partial<Loggable & Timeoutable & Withinable & Shadow> | undefined>
|
||||||
): Chainable<JQuery<HTMLElement>>;
|
): Chainable<JQuery<HTMLElement>>;
|
||||||
|
ifCanvasVersion<T1, T2>(getterV1: () => T1, getterV2: () => T2): T1 | T2;
|
||||||
findChildByTestId(childTestId: string): Chainable<JQuery<HTMLElement>>;
|
findChildByTestId(childTestId: string): Chainable<JQuery<HTMLElement>>;
|
||||||
/**
|
/**
|
||||||
* Creates a workflow from the given fixture and optionally renames it.
|
* Creates a workflow from the given fixture and optionally renames it.
|
||||||
|
|
|
@ -3,3 +3,7 @@ import { nanoid } from 'nanoid';
|
||||||
export function getUniqueWorkflowName(workflowNamePrefix?: string) {
|
export function getUniqueWorkflowName(workflowNamePrefix?: string) {
|
||||||
return workflowNamePrefix ? `${workflowNamePrefix} ${nanoid(12)}` : nanoid(12);
|
return workflowNamePrefix ? `${workflowNamePrefix} ${nanoid(12)}` : nanoid(12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCanvasV2() {
|
||||||
|
return Cypress.env('NODE_VIEW_VERSION') === 2;
|
||||||
|
}
|
||||||
|
|
|
@ -31,6 +31,30 @@ WORKDIR /home/node
|
||||||
COPY --from=builder /compiled /usr/local/lib/node_modules/n8n
|
COPY --from=builder /compiled /usr/local/lib/node_modules/n8n
|
||||||
COPY docker/images/n8n/docker-entrypoint.sh /
|
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 \
|
RUN \
|
||||||
cd /usr/local/lib/node_modules/n8n && \
|
cd /usr/local/lib/node_modules/n8n && \
|
||||||
npm rebuild sqlite3 && \
|
npm rebuild sqlite3 && \
|
||||||
|
|
|
@ -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 && \
|
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
|
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 /
|
COPY docker-entrypoint.sh /
|
||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
|
|
22
docker/images/n8n/n8n-task-runners.json
Normal file
22
docker/images/n8n/n8n-task-runners.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
10
package.json
10
package.json
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "n8n-monorepo",
|
"name": "n8n-monorepo",
|
||||||
"version": "1.63.0",
|
"version": "1.65.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.15",
|
"node": ">=20.15",
|
||||||
|
@ -43,6 +43,7 @@
|
||||||
"@biomejs/biome": "^1.9.0",
|
"@biomejs/biome": "^1.9.0",
|
||||||
"@n8n_io/eslint-config": "workspace:*",
|
"@n8n_io/eslint-config": "workspace:*",
|
||||||
"@types/jest": "^29.5.3",
|
"@types/jest": "^29.5.3",
|
||||||
|
"@types/node": "*",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"jest": "^29.6.2",
|
"jest": "^29.6.2",
|
||||||
"jest-environment-jsdom": "^29.6.2",
|
"jest-environment-jsdom": "^29.6.2",
|
||||||
|
@ -57,8 +58,8 @@
|
||||||
"run-script-os": "^1.0.7",
|
"run-script-os": "^1.0.7",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.1.1",
|
"ts-jest": "^29.1.1",
|
||||||
"tsc-alias": "^1.8.7",
|
"tsc-alias": "^1.8.10",
|
||||||
"tsc-watch": "^6.0.4",
|
"tsc-watch": "^6.2.0",
|
||||||
"turbo": "2.1.2",
|
"turbo": "2.1.2",
|
||||||
"typescript": "*",
|
"typescript": "*",
|
||||||
"zx": "^8.1.4"
|
"zx": "^8.1.4"
|
||||||
|
@ -87,7 +88,8 @@
|
||||||
"pyodide@0.23.4": "patches/pyodide@0.23.4.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",
|
"@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch",
|
||||||
"@types/ws@8.5.4": "patches/@types__ws@8.5.4.patch",
|
"@types/ws@8.5.4": "patches/@types__ws@8.5.4.patch",
|
||||||
"@types/uuencode@0.0.3": "patches/@types__uuencode@0.0.3.patch"
|
"@types/uuencode@0.0.3": "patches/@types__uuencode@0.0.3.patch",
|
||||||
|
"@langchain/core@0.3.3": "patches/@langchain__core@0.3.3.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/api-types",
|
"name": "@n8n/api-types",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
@ -21,6 +21,7 @@
|
||||||
"dist/**/*"
|
"dist/**/*"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@n8n/config": "workspace:*",
|
||||||
"n8n-workflow": "workspace:*"
|
"n8n-workflow": "workspace:*"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { FrontendBetaFeatures } from '@n8n/config';
|
||||||
import type { ExpressionEvaluatorType, LogLevel, WorkflowSettings } from 'n8n-workflow';
|
import type { ExpressionEvaluatorType, LogLevel, WorkflowSettings } from 'n8n-workflow';
|
||||||
|
|
||||||
export interface IVersionNotificationSettings {
|
export interface IVersionNotificationSettings {
|
||||||
|
@ -169,4 +170,5 @@ export interface FrontendSettings {
|
||||||
security: {
|
security: {
|
||||||
blockFileAccessToN8nFiles: boolean;
|
blockFileAccessToN8nFiles: boolean;
|
||||||
};
|
};
|
||||||
|
betaFeatures: FrontendBetaFeatures[];
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# n8n benchmarking tool
|
# 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
|
## Directory structure
|
||||||
|
|
||||||
|
@ -12,6 +12,39 @@ packages/@n8n/benchmark
|
||||||
├── scripts Orchestration scripts
|
├── 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
|
## Running the entire benchmark suite
|
||||||
|
|
||||||
The benchmark suite consists of [benchmark scenarios](#benchmark-scenarios) and different [n8n setups](#n8n-setups).
|
The benchmark suite consists of [benchmark scenarios](#benchmark-scenarios) and different [n8n setups](#n8n-setups).
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/n8n-benchmark",
|
"name": "@n8n/n8n-benchmark",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"description": "Cli for running benchmark tests for n8n",
|
"description": "Cli for running benchmark tests for n8n",
|
||||||
"main": "dist/index",
|
"main": "dist/index",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -17,7 +17,7 @@
|
||||||
"benchmark-locally": "pnpm benchmark --env local",
|
"benchmark-locally": "pnpm benchmark --env local",
|
||||||
"provision-cloud-env": "zx scripts/provision-cloud-env.mjs",
|
"provision-cloud-env": "zx scripts/provision-cloud-env.mjs",
|
||||||
"destroy-cloud-env": "zx scripts/destroy-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": {
|
"engines": {
|
||||||
"node": ">=20.10"
|
"node": ">=20.10"
|
||||||
|
@ -41,10 +41,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/convict": "^6.1.1",
|
"@types/convict": "^6.1.1",
|
||||||
"@types/k6": "^0.52.0",
|
"@types/k6": "^0.52.0"
|
||||||
"@types/node": "^20.14.8",
|
|
||||||
"tsc-alias": "^1.8.7",
|
|
||||||
"typescript": "^5.5.2"
|
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"n8n-benchmark": "./bin/n8n-benchmark"
|
"n8n-benchmark": "./bin/n8n-benchmark"
|
||||||
|
|
|
@ -15,6 +15,12 @@ export default function () {
|
||||||
|
|
||||||
const res = http.post(`${apiBaseUrl}/webhook/binary-files-benchmark`, data);
|
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, {
|
check(res, {
|
||||||
'is status 200': (r) => r.status === 200,
|
'is status 200': (r) => r.status === 200,
|
||||||
'has correct content type': (r) =>
|
'has correct content type': (r) =>
|
||||||
|
|
|
@ -6,6 +6,12 @@ const apiBaseUrl = __ENV.API_BASE_URL;
|
||||||
export default function () {
|
export default function () {
|
||||||
const res = http.post(`${apiBaseUrl}/webhook/benchmark-http-node`);
|
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, {
|
check(res, {
|
||||||
'is status 200': (r) => r.status === 200,
|
'is status 200': (r) => r.status === 200,
|
||||||
'http requests were OK': (r) => {
|
'http requests were OK': (r) => {
|
||||||
|
|
|
@ -5,6 +5,13 @@ const apiBaseUrl = __ENV.API_BASE_URL;
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
const res = http.post(`${apiBaseUrl}/webhook/code-node-benchmark`, {});
|
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, {
|
check(res, {
|
||||||
'is status 200': (r) => r.status === 200,
|
'is status 200': (r) => r.status === 200,
|
||||||
'has items in response': (r) => {
|
'has items in response': (r) => {
|
||||||
|
|
|
@ -5,6 +5,13 @@ const apiBaseUrl = __ENV.API_BASE_URL;
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
const res = http.post(`${apiBaseUrl}/webhook/set-expressions-benchmark`, {});
|
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, {
|
check(res, {
|
||||||
'is status 200': (r) => r.status === 200,
|
'is status 200': (r) => r.status === 200,
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,5 +3,5 @@
|
||||||
"name": "SingleWebhook",
|
"name": "SingleWebhook",
|
||||||
"description": "A single webhook trigger that responds with a 200 status code",
|
"description": "A single webhook trigger that responds with a 200 status code",
|
||||||
"scenarioData": { "workflowFiles": ["single-webhook.json"] },
|
"scenarioData": { "workflowFiles": ["single-webhook.json"] },
|
||||||
"scriptPath": "single-webhook.script.ts"
|
"scriptPath": "single-webhook.script.js"
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,13 @@ const apiBaseUrl = __ENV.API_BASE_URL;
|
||||||
|
|
||||||
export default function () {
|
export default function () {
|
||||||
const res = http.get(`${apiBaseUrl}/webhook/single-webhook`);
|
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, {
|
check(res, {
|
||||||
'is status 200': (r) => r.status === 200,
|
'is status 200': (r) => r.status === 200,
|
||||||
});
|
});
|
|
@ -176,7 +176,7 @@ services:
|
||||||
|
|
||||||
# Load balancer that acts as an entry point for n8n
|
# Load balancer that acts as an entry point for n8n
|
||||||
n8n:
|
n8n:
|
||||||
image: nginx:latest
|
image: nginx:1.27.2
|
||||||
ports:
|
ports:
|
||||||
- '5678:80'
|
- '5678:80'
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
@ -3,6 +3,7 @@ events {}
|
||||||
http {
|
http {
|
||||||
client_max_body_size 50M;
|
client_max_body_size 50M;
|
||||||
access_log off;
|
access_log off;
|
||||||
|
error_log /dev/stderr warn;
|
||||||
|
|
||||||
upstream backend {
|
upstream backend {
|
||||||
server n8n_main1:5678;
|
server n8n_main1:5678;
|
||||||
|
|
|
@ -78,12 +78,6 @@ async function runBenchmarksOnVm(config, benchmarkEnv) {
|
||||||
const bootstrapScriptPath = path.join(scriptsDir, 'bootstrap.sh');
|
const bootstrapScriptPath = path.join(scriptsDir, 'bootstrap.sh');
|
||||||
await sshClient.ssh(`chmod a+x ${bootstrapScriptPath} && ${bootstrapScriptPath}`);
|
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
|
// Give some time for the VM to be ready
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
|
@ -13,6 +13,10 @@ export default class RunCommand extends Command {
|
||||||
|
|
||||||
static flags = {
|
static flags = {
|
||||||
testScenariosPath,
|
testScenariosPath,
|
||||||
|
scenarioFilter: Flags.string({
|
||||||
|
char: 'f',
|
||||||
|
description: 'Filter scenarios by name',
|
||||||
|
}),
|
||||||
scenarioNamePrefix: Flags.string({
|
scenarioNamePrefix: Flags.string({
|
||||||
description: 'Prefix for the scenario name',
|
description: 'Prefix for the scenario name',
|
||||||
default: 'Unnamed',
|
default: 'Unnamed',
|
||||||
|
@ -95,7 +99,7 @@ export default class RunCommand extends Command {
|
||||||
flags.scenarioNamePrefix,
|
flags.scenarioNamePrefix,
|
||||||
);
|
);
|
||||||
|
|
||||||
const allScenarios = scenarioLoader.loadAll(flags.testScenariosPath);
|
const allScenarios = scenarioLoader.loadAll(flags.testScenariosPath, flags.scenarioFilter);
|
||||||
|
|
||||||
await scenarioRunner.runManyScenarios(allScenarios);
|
await scenarioRunner.runManyScenarios(allScenarios);
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ export class ScenarioLoader {
|
||||||
/**
|
/**
|
||||||
* Loads all scenarios from the given path
|
* Loads all scenarios from the given path
|
||||||
*/
|
*/
|
||||||
loadAll(pathToScenarios: string): Scenario[] {
|
loadAll(pathToScenarios: string, filter?: string): Scenario[] {
|
||||||
pathToScenarios = path.resolve(pathToScenarios);
|
pathToScenarios = path.resolve(pathToScenarios);
|
||||||
const scenarioFolders = fs
|
const scenarioFolders = fs
|
||||||
.readdirSync(pathToScenarios, { withFileTypes: true })
|
.readdirSync(pathToScenarios, { withFileTypes: true })
|
||||||
|
@ -18,6 +18,9 @@ export class ScenarioLoader {
|
||||||
const scenarios: Scenario[] = [];
|
const scenarios: Scenario[] = [];
|
||||||
|
|
||||||
for (const folder of scenarioFolders) {
|
for (const folder of scenarioFolders) {
|
||||||
|
if (filter && !folder.toLowerCase().includes(filter.toLowerCase())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const scenarioPath = path.join(pathToScenarios, folder);
|
const scenarioPath = path.join(pathToScenarios, folder);
|
||||||
const manifestFileName = `${folder}.manifest.json`;
|
const manifestFileName = `${folder}.manifest.json`;
|
||||||
const scenarioManifestPath = path.join(pathToScenarios, folder, manifestFileName);
|
const scenarioManifestPath = path.join(pathToScenarios, folder, manifestFileName);
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { sleep } from 'zx';
|
||||||
|
|
||||||
import { AuthenticatedN8nApiClient } from '@/n8n-api-client/authenticated-n8n-api-client';
|
import { AuthenticatedN8nApiClient } from '@/n8n-api-client/authenticated-n8n-api-client';
|
||||||
import type { N8nApiClient } from '@/n8n-api-client/n8n-api-client';
|
import type { N8nApiClient } from '@/n8n-api-client/n8n-api-client';
|
||||||
import type { ScenarioDataFileLoader } from '@/scenario/scenario-data-loader';
|
import type { ScenarioDataFileLoader } from '@/scenario/scenario-data-loader';
|
||||||
|
@ -47,6 +49,10 @@ export class ScenarioRunner {
|
||||||
const testData = await this.dataLoader.loadDataForScenario(scenario);
|
const testData = await this.dataLoader.loadDataForScenario(scenario);
|
||||||
await testDataImporter.importTestScenarioData(testData.workflows);
|
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');
|
console.log('Executing scenario script');
|
||||||
await this.k6Executor.executeTestScenario(scenario, {
|
await this.k6Executor.executeTestScenario(scenario, {
|
||||||
scenarioRunName,
|
scenarioRunName,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/chat",
|
"name": "@n8n/chat",
|
||||||
"version": "0.28.0",
|
"version": "0.29.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm run storybook",
|
"dev": "pnpm run storybook",
|
||||||
"build": "pnpm build:vite && pnpm build:bundle",
|
"build": "pnpm build:vite && pnpm build:bundle",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@n8n/config",
|
"name": "@n8n/config",
|
||||||
"version": "1.13.0",
|
"version": "1.15.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf dist .turbo",
|
"clean": "rimraf dist .turbo",
|
||||||
"dev": "pnpm watch",
|
"dev": "pnpm watch",
|
||||||
|
|
11
packages/@n8n/config/src/configs/frontend.config.ts
Normal file
11
packages/@n8n/config/src/configs/frontend.config.ts
Normal 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> = [];
|
||||||
|
}
|
15
packages/@n8n/config/src/configs/generic.config.ts
Normal file
15
packages/@n8n/config/src/configs/generic.config.ts
Normal 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;
|
||||||
|
}
|
28
packages/@n8n/config/src/configs/license.config.ts
Normal file
28
packages/@n8n/config/src/configs/license.config.ts
Normal 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 = '';
|
||||||
|
}
|
|
@ -1,14 +1,18 @@
|
||||||
import { Config, Env, Nested } from '../decorators';
|
import { Config, Env, Nested } from '../decorators';
|
||||||
import { StringArray } from '../utils';
|
import { StringArray } from '../utils';
|
||||||
|
|
||||||
/**
|
/** Scopes (areas of functionality) to filter logs by. */
|
||||||
* Scopes (areas of functionality) to filter logs by.
|
export const LOG_SCOPES = [
|
||||||
*
|
'concurrency',
|
||||||
* `executions` -> execution lifecycle
|
'external-secrets',
|
||||||
* `license` -> license SDK
|
'license',
|
||||||
* `scaling` -> scaling mode
|
'multi-main-setup',
|
||||||
*/
|
'pubsub',
|
||||||
export const LOG_SCOPES = ['executions', 'license', 'scaling'] as const;
|
'redis',
|
||||||
|
'scaling',
|
||||||
|
'waiting-executions',
|
||||||
|
'task-runner',
|
||||||
|
] as const;
|
||||||
|
|
||||||
export type LogScope = (typeof LOG_SCOPES)[number];
|
export type LogScope = (typeof LOG_SCOPES)[number];
|
||||||
|
|
||||||
|
@ -59,14 +63,20 @@ export class LoggingConfig {
|
||||||
/**
|
/**
|
||||||
* Scopes to filter logs by. Nothing is filtered by default.
|
* Scopes to filter logs by. Nothing is filtered by default.
|
||||||
*
|
*
|
||||||
* Currently supported log scopes:
|
* Supported log scopes:
|
||||||
* - `executions`
|
*
|
||||||
|
* - `concurrency`
|
||||||
|
* - `external-secrets`
|
||||||
* - `license`
|
* - `license`
|
||||||
|
* - `multi-main-setup`
|
||||||
|
* - `pubsub`
|
||||||
|
* - `redis`
|
||||||
* - `scaling`
|
* - `scaling`
|
||||||
|
* - `waiting-executions`
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* `N8N_LOG_SCOPES=license`
|
* `N8N_LOG_SCOPES=license`
|
||||||
* `N8N_LOG_SCOPES=license,executions`
|
* `N8N_LOG_SCOPES=license,waiting-executions`
|
||||||
*/
|
*/
|
||||||
@Env('N8N_LOG_SCOPES')
|
@Env('N8N_LOG_SCOPES')
|
||||||
scopes: StringArray<LogScope> = [];
|
scopes: StringArray<LogScope> = [];
|
||||||
|
|
16
packages/@n8n/config/src/configs/multi-main-setup.config.ts
Normal file
16
packages/@n8n/config/src/configs/multi-main-setup.config.ts
Normal 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;
|
||||||
|
}
|
|
@ -1,11 +1,23 @@
|
||||||
import { Config, Env } from '../decorators';
|
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
|
@Config
|
||||||
export class TaskRunnersConfig {
|
export class TaskRunnersConfig {
|
||||||
// Defaults to true for now
|
// Defaults to true for now
|
||||||
@Env('N8N_RUNNERS_DISABLED')
|
@Env('N8N_RUNNERS_DISABLED')
|
||||||
disabled: boolean = true;
|
disabled: boolean = true;
|
||||||
|
|
||||||
|
// Defaults to true for now
|
||||||
|
@Env('N8N_RUNNERS_MODE')
|
||||||
|
mode: TaskRunnerMode = 'internal_childprocess';
|
||||||
|
|
||||||
@Env('N8N_RUNNERS_PATH')
|
@Env('N8N_RUNNERS_PATH')
|
||||||
path: string = '/runners';
|
path: string = '/runners';
|
||||||
|
|
||||||
|
@ -18,5 +30,24 @@ export class TaskRunnersConfig {
|
||||||
|
|
||||||
/** IP address task runners server should listen on */
|
/** IP address task runners server should listen on */
|
||||||
@Env('N8N_RUNNERS_SERVER_LISTEN_ADDRESS')
|
@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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -82,10 +82,6 @@ class BullConfig {
|
||||||
@Nested
|
@Nested
|
||||||
redis: RedisConfig;
|
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 */
|
/** @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')
|
@Env('QUEUE_WORKER_TIMEOUT')
|
||||||
gracefulShutdownTimeout: number = 30;
|
gracefulShutdownTimeout: number = 30;
|
||||||
|
|
27
packages/@n8n/config/src/configs/security.config.ts
Normal file
27
packages/@n8n/config/src/configs/security.config.ts
Normal 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;
|
||||||
|
}
|
|
@ -5,12 +5,15 @@ import { EndpointsConfig } from './configs/endpoints.config';
|
||||||
import { EventBusConfig } from './configs/event-bus.config';
|
import { EventBusConfig } from './configs/event-bus.config';
|
||||||
import { ExternalSecretsConfig } from './configs/external-secrets.config';
|
import { ExternalSecretsConfig } from './configs/external-secrets.config';
|
||||||
import { ExternalStorageConfig } from './configs/external-storage.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 { LoggingConfig } from './configs/logging.config';
|
||||||
|
import { MultiMainSetupConfig } from './configs/multi-main-setup.config';
|
||||||
import { NodesConfig } from './configs/nodes.config';
|
import { NodesConfig } from './configs/nodes.config';
|
||||||
import { PublicApiConfig } from './configs/public-api.config';
|
import { PublicApiConfig } from './configs/public-api.config';
|
||||||
import { TaskRunnersConfig } from './configs/runners.config';
|
import { TaskRunnersConfig } from './configs/runners.config';
|
||||||
export { TaskRunnersConfig } from './configs/runners.config';
|
|
||||||
import { ScalingModeConfig } from './configs/scaling-mode.config';
|
import { ScalingModeConfig } from './configs/scaling-mode.config';
|
||||||
|
import { SecurityConfig } from './configs/security.config';
|
||||||
import { SentryConfig } from './configs/sentry.config';
|
import { SentryConfig } from './configs/sentry.config';
|
||||||
import { TemplatesConfig } from './configs/templates.config';
|
import { TemplatesConfig } from './configs/templates.config';
|
||||||
import { UserManagementConfig } from './configs/user-management.config';
|
import { UserManagementConfig } from './configs/user-management.config';
|
||||||
|
@ -18,6 +21,10 @@ import { VersionNotificationsConfig } from './configs/version-notifications.conf
|
||||||
import { WorkflowsConfig } from './configs/workflows.config';
|
import { WorkflowsConfig } from './configs/workflows.config';
|
||||||
import { Config, Env, Nested } from './decorators';
|
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 { FrontendBetaFeatures, FrontendConfig } from './configs/frontend.config';
|
||||||
export { LOG_SCOPES } from './configs/logging.config';
|
export { LOG_SCOPES } from './configs/logging.config';
|
||||||
export type { LogScope } from './configs/logging.config';
|
export type { LogScope } from './configs/logging.config';
|
||||||
|
|
||||||
|
@ -93,4 +100,16 @@ export class GlobalConfig {
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
taskRunners: TaskRunnersConfig;
|
taskRunners: TaskRunnersConfig;
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
multiMainSetup: MultiMainSetupConfig;
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
generic: GenericConfig;
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
license: LicenseConfig;
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
security: SecurityConfig;
|
||||||
}
|
}
|
||||||
|
|
|
@ -211,7 +211,6 @@ describe('GlobalConfig', () => {
|
||||||
clusterNodes: '',
|
clusterNodes: '',
|
||||||
tls: false,
|
tls: false,
|
||||||
},
|
},
|
||||||
queueRecoveryInterval: 60,
|
|
||||||
gracefulShutdownTimeout: 30,
|
gracefulShutdownTimeout: 30,
|
||||||
prefix: 'bull',
|
prefix: 'bull',
|
||||||
settings: {
|
settings: {
|
||||||
|
@ -224,10 +223,16 @@ describe('GlobalConfig', () => {
|
||||||
},
|
},
|
||||||
taskRunners: {
|
taskRunners: {
|
||||||
disabled: true,
|
disabled: true,
|
||||||
|
mode: 'internal_childprocess',
|
||||||
path: '/runners',
|
path: '/runners',
|
||||||
authToken: '',
|
authToken: '',
|
||||||
listen_address: '127.0.0.1',
|
listenAddress: '127.0.0.1',
|
||||||
|
maxPayload: 1024 * 1024 * 1024,
|
||||||
port: 5679,
|
port: 5679,
|
||||||
|
launcherPath: '',
|
||||||
|
launcherRunner: 'javascript',
|
||||||
|
maxOldSpaceSize: '',
|
||||||
|
maxConcurrency: 5,
|
||||||
},
|
},
|
||||||
sentry: {
|
sentry: {
|
||||||
backendDsn: '',
|
backendDsn: '',
|
||||||
|
@ -243,6 +248,29 @@ describe('GlobalConfig', () => {
|
||||||
},
|
},
|
||||||
scopes: [],
|
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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
it('should use all default values when no env variables are defined', () => {
|
it('should use all default values when no env variables are defined', () => {
|
||||||
|
|
21
packages/@n8n/json-schema-to-zod/.eslintrc.js
Normal file
21
packages/@n8n/json-schema-to-zod/.eslintrc.js
Normal 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',
|
||||||
|
},
|
||||||
|
};
|
4
packages/@n8n/json-schema-to-zod/.gitignore
vendored
Normal file
4
packages/@n8n/json-schema-to-zod/.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
test/output
|
3
packages/@n8n/json-schema-to-zod/.npmignore
Normal file
3
packages/@n8n/json-schema-to-zod/.npmignore
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
src
|
||||||
|
tsconfig*
|
||||||
|
test
|
16
packages/@n8n/json-schema-to-zod/LICENSE
Normal file
16
packages/@n8n/json-schema-to-zod/LICENSE
Normal 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.
|
34
packages/@n8n/json-schema-to-zod/README.md
Normal file
34
packages/@n8n/json-schema-to-zod/README.md
Normal 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).
|
5
packages/@n8n/json-schema-to-zod/jest.config.js
Normal file
5
packages/@n8n/json-schema-to-zod/jest.config.js
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
/** @type {import('jest').Config} */
|
||||||
|
module.exports = {
|
||||||
|
...require('../../../jest.config'),
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/test/extend-expect.ts'],
|
||||||
|
};
|
68
packages/@n8n/json-schema-to-zod/package.json
Normal file
68
packages/@n8n/json-schema-to-zod/package.json
Normal 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:"
|
||||||
|
}
|
||||||
|
}
|
1
packages/@n8n/json-schema-to-zod/postcjs.js
Normal file
1
packages/@n8n/json-schema-to-zod/postcjs.js
Normal file
|
@ -0,0 +1 @@
|
||||||
|
require('fs').writeFileSync('./dist/cjs/package.json', '{"type":"commonjs"}', 'utf-8');
|
1
packages/@n8n/json-schema-to-zod/postesm.js
Normal file
1
packages/@n8n/json-schema-to-zod/postesm.js
Normal file
|
@ -0,0 +1 @@
|
||||||
|
require('fs').writeFileSync('./dist/esm/package.json', '{"type":"module"}', 'utf-8');
|
2
packages/@n8n/json-schema-to-zod/src/index.ts
Normal file
2
packages/@n8n/json-schema-to-zod/src/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export type * from './types';
|
||||||
|
export { jsonSchemaToZod } from './json-schema-to-zod.js';
|
15
packages/@n8n/json-schema-to-zod/src/json-schema-to-zod.ts
Normal file
15
packages/@n8n/json-schema-to-zod/src/json-schema-to-zod.ts
Normal 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;
|
||||||
|
};
|
46
packages/@n8n/json-schema-to-zod/src/parsers/parse-all-of.ts
Normal file
46
packages/@n8n/json-schema-to-zod/src/parsers/parse-all-of.ts
Normal 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));
|
||||||
|
}
|
19
packages/@n8n/json-schema-to-zod/src/parsers/parse-any-of.ts
Normal file
19
packages/@n8n/json-schema-to-zod/src/parsers/parse-any-of.ts
Normal 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();
|
||||||
|
};
|
34
packages/@n8n/json-schema-to-zod/src/parsers/parse-array.ts
Normal file
34
packages/@n8n/json-schema-to-zod/src/parsers/parse-array.ts
Normal 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;
|
||||||
|
};
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { JsonSchemaObject } from '../types';
|
||||||
|
|
||||||
|
export const parseBoolean = (_jsonSchema: JsonSchemaObject & { type: 'boolean' }) => {
|
||||||
|
return z.boolean();
|
||||||
|
};
|
|
@ -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);
|
||||||
|
};
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { JsonSchemaObject } from '../types';
|
||||||
|
|
||||||
|
export const parseDefault = (_jsonSchema: JsonSchemaObject) => {
|
||||||
|
return z.any();
|
||||||
|
};
|
25
packages/@n8n/json-schema-to-zod/src/parsers/parse-enum.ts
Normal file
25
packages/@n8n/json-schema-to-zod/src/parsers/parse-enum.ts
Normal 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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
|
@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
|
@ -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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
};
|
15
packages/@n8n/json-schema-to-zod/src/parsers/parse-not.ts
Normal file
15
packages/@n8n/json-schema-to-zod/src/parsers/parse-not.ts
Normal 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',
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { JsonSchemaObject } from '../types';
|
||||||
|
|
||||||
|
export const parseNull = (_jsonSchema: JsonSchemaObject & { type: 'null' }) => {
|
||||||
|
return z.null();
|
||||||
|
};
|
|
@ -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();
|
||||||
|
};
|
88
packages/@n8n/json-schema-to-zod/src/parsers/parse-number.ts
Normal file
88
packages/@n8n/json-schema-to-zod/src/parsers/parse-number.ts
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { JsonSchemaObject } from '../types';
|
||||||
|
import { extendSchemaWithMessage } from '../utils/extend-schema';
|
||||||
|
|
||||||
|
export const parseNumber = (jsonSchema: JsonSchemaObject & { type: 'number' | 'integer' }) => {
|
||||||
|
let zodSchema = z.number();
|
||||||
|
|
||||||
|
let isInteger = false;
|
||||||
|
if (jsonSchema.type === 'integer') {
|
||||||
|
isInteger = true;
|
||||||
|
zodSchema = extendSchemaWithMessage(zodSchema, jsonSchema, 'type', (zs, _, errorMsg) =>
|
||||||
|
zs.int(errorMsg),
|
||||||
|
);
|
||||||
|
} else if (jsonSchema.format === 'int64') {
|
||||||
|
isInteger = true;
|
||||||
|
zodSchema = extendSchemaWithMessage(zodSchema, jsonSchema, 'format', (zs, _, errorMsg) =>
|
||||||
|
zs.int(errorMsg),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
zodSchema = extendSchemaWithMessage(
|
||||||
|
zodSchema,
|
||||||
|
jsonSchema,
|
||||||
|
'multipleOf',
|
||||||
|
(zs, multipleOf, errorMsg) => {
|
||||||
|
if (multipleOf === 1) {
|
||||||
|
if (isInteger) return zs;
|
||||||
|
|
||||||
|
return zs.int(errorMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return zs.multipleOf(multipleOf, errorMsg);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (typeof jsonSchema.minimum === 'number') {
|
||||||
|
if (jsonSchema.exclusiveMinimum === true) {
|
||||||
|
zodSchema = extendSchemaWithMessage(
|
||||||
|
zodSchema,
|
||||||
|
jsonSchema,
|
||||||
|
'minimum',
|
||||||
|
(zs, minimum, errorMsg) => zs.gt(minimum, errorMsg),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
zodSchema = extendSchemaWithMessage(
|
||||||
|
zodSchema,
|
||||||
|
jsonSchema,
|
||||||
|
'minimum',
|
||||||
|
(zs, minimum, errorMsg) => zs.gte(minimum, errorMsg),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (typeof jsonSchema.exclusiveMinimum === 'number') {
|
||||||
|
zodSchema = extendSchemaWithMessage(
|
||||||
|
zodSchema,
|
||||||
|
jsonSchema,
|
||||||
|
'exclusiveMinimum',
|
||||||
|
(zs, exclusiveMinimum, errorMsg) => zs.gt(exclusiveMinimum as number, errorMsg),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof jsonSchema.maximum === 'number') {
|
||||||
|
if (jsonSchema.exclusiveMaximum === true) {
|
||||||
|
zodSchema = extendSchemaWithMessage(
|
||||||
|
zodSchema,
|
||||||
|
jsonSchema,
|
||||||
|
'maximum',
|
||||||
|
(zs, maximum, errorMsg) => zs.lt(maximum, errorMsg),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
zodSchema = extendSchemaWithMessage(
|
||||||
|
zodSchema,
|
||||||
|
jsonSchema,
|
||||||
|
'maximum',
|
||||||
|
(zs, maximum, errorMsg) => zs.lte(maximum, errorMsg),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (typeof jsonSchema.exclusiveMaximum === 'number') {
|
||||||
|
zodSchema = extendSchemaWithMessage(
|
||||||
|
zodSchema,
|
||||||
|
jsonSchema,
|
||||||
|
'exclusiveMaximum',
|
||||||
|
(zs, exclusiveMaximum, errorMsg) => zs.lt(exclusiveMaximum as number, errorMsg),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return zodSchema;
|
||||||
|
};
|
219
packages/@n8n/json-schema-to-zod/src/parsers/parse-object.ts
Normal file
219
packages/@n8n/json-schema-to-zod/src/parsers/parse-object.ts
Normal file
|
@ -0,0 +1,219 @@
|
||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
import { parseAllOf } from './parse-all-of';
|
||||||
|
import { parseAnyOf } from './parse-any-of';
|
||||||
|
import { parseOneOf } from './parse-one-of';
|
||||||
|
import { parseSchema } from './parse-schema';
|
||||||
|
import type { JsonSchemaObject, Refs } from '../types';
|
||||||
|
import { its } from '../utils/its';
|
||||||
|
|
||||||
|
function parseObjectProperties(objectSchema: JsonSchemaObject & { type: 'object' }, refs: Refs) {
|
||||||
|
if (!objectSchema.properties) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const propertyKeys = Object.keys(objectSchema.properties);
|
||||||
|
if (propertyKeys.length === 0) {
|
||||||
|
return z.object({});
|
||||||
|
}
|
||||||
|
|
||||||
|
const properties: Record<string, z.ZodTypeAny> = {};
|
||||||
|
|
||||||
|
for (const key of propertyKeys) {
|
||||||
|
const propJsonSchema = objectSchema.properties[key];
|
||||||
|
|
||||||
|
const propZodSchema = parseSchema(propJsonSchema, {
|
||||||
|
...refs,
|
||||||
|
path: [...refs.path, 'properties', key],
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasDefault = typeof propJsonSchema === 'object' && propJsonSchema.default !== undefined;
|
||||||
|
|
||||||
|
const required = Array.isArray(objectSchema.required)
|
||||||
|
? objectSchema.required.includes(key)
|
||||||
|
: typeof propJsonSchema === 'object' && propJsonSchema.required === true;
|
||||||
|
|
||||||
|
const isOptional = !hasDefault && !required;
|
||||||
|
|
||||||
|
properties[key] = isOptional ? propZodSchema.optional() : propZodSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
return z.object(properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseObject(
|
||||||
|
objectSchema: JsonSchemaObject & { type: 'object' },
|
||||||
|
refs: Refs,
|
||||||
|
): z.ZodTypeAny {
|
||||||
|
const hasPatternProperties = Object.keys(objectSchema.patternProperties ?? {}).length > 0;
|
||||||
|
|
||||||
|
const propertiesSchema:
|
||||||
|
| z.ZodObject<Record<string, z.ZodTypeAny>, 'strip', z.ZodTypeAny>
|
||||||
|
| undefined = parseObjectProperties(objectSchema, refs);
|
||||||
|
let zodSchema: z.ZodTypeAny | undefined = propertiesSchema;
|
||||||
|
|
||||||
|
const additionalProperties =
|
||||||
|
objectSchema.additionalProperties !== undefined
|
||||||
|
? parseSchema(objectSchema.additionalProperties, {
|
||||||
|
...refs,
|
||||||
|
path: [...refs.path, 'additionalProperties'],
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (objectSchema.patternProperties) {
|
||||||
|
const parsedPatternProperties = Object.fromEntries(
|
||||||
|
Object.entries(objectSchema.patternProperties).map(([key, value]) => {
|
||||||
|
return [
|
||||||
|
key,
|
||||||
|
parseSchema(value, {
|
||||||
|
...refs,
|
||||||
|
path: [...refs.path, 'patternProperties', key],
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const patternPropertyValues = Object.values(parsedPatternProperties);
|
||||||
|
|
||||||
|
if (propertiesSchema) {
|
||||||
|
if (additionalProperties) {
|
||||||
|
zodSchema = propertiesSchema.catchall(
|
||||||
|
z.union([...patternPropertyValues, additionalProperties] as [z.ZodTypeAny, z.ZodTypeAny]),
|
||||||
|
);
|
||||||
|
} else if (Object.keys(parsedPatternProperties).length > 1) {
|
||||||
|
zodSchema = propertiesSchema.catchall(
|
||||||
|
z.union(patternPropertyValues as [z.ZodTypeAny, z.ZodTypeAny]),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
zodSchema = propertiesSchema.catchall(patternPropertyValues[0]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (additionalProperties) {
|
||||||
|
zodSchema = z.record(
|
||||||
|
z.union([...patternPropertyValues, additionalProperties] as [z.ZodTypeAny, z.ZodTypeAny]),
|
||||||
|
);
|
||||||
|
} else if (patternPropertyValues.length > 1) {
|
||||||
|
zodSchema = z.record(z.union(patternPropertyValues as [z.ZodTypeAny, z.ZodTypeAny]));
|
||||||
|
} else {
|
||||||
|
zodSchema = z.record(patternPropertyValues[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectPropertyKeys = new Set(Object.keys(objectSchema.properties ?? {}));
|
||||||
|
zodSchema = zodSchema.superRefine((value: Record<string, unknown>, ctx) => {
|
||||||
|
for (const key in value) {
|
||||||
|
let wasMatched = objectPropertyKeys.has(key);
|
||||||
|
|
||||||
|
for (const patternPropertyKey in objectSchema.patternProperties) {
|
||||||
|
const regex = new RegExp(patternPropertyKey);
|
||||||
|
if (key.match(regex)) {
|
||||||
|
wasMatched = true;
|
||||||
|
const result = parsedPatternProperties[patternPropertyKey].safeParse(value[key]);
|
||||||
|
if (!result.success) {
|
||||||
|
ctx.addIssue({
|
||||||
|
path: [...ctx.path, key],
|
||||||
|
code: 'custom',
|
||||||
|
message: `Invalid input: Key matching regex /${key}/ must match schema`,
|
||||||
|
params: {
|
||||||
|
issues: result.error.issues,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wasMatched && additionalProperties) {
|
||||||
|
const result = additionalProperties.safeParse(value[key]);
|
||||||
|
if (!result.success) {
|
||||||
|
ctx.addIssue({
|
||||||
|
path: [...ctx.path, key],
|
||||||
|
code: 'custom',
|
||||||
|
message: 'Invalid input: must match catchall schema',
|
||||||
|
params: {
|
||||||
|
issues: result.error.issues,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let output: z.ZodTypeAny;
|
||||||
|
if (propertiesSchema) {
|
||||||
|
if (hasPatternProperties) {
|
||||||
|
output = zodSchema!;
|
||||||
|
} else if (additionalProperties) {
|
||||||
|
if (additionalProperties instanceof z.ZodNever) {
|
||||||
|
output = propertiesSchema.strict();
|
||||||
|
} else {
|
||||||
|
output = propertiesSchema.catchall(additionalProperties);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output = zodSchema!;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (hasPatternProperties) {
|
||||||
|
output = zodSchema!;
|
||||||
|
} else if (additionalProperties) {
|
||||||
|
output = z.record(additionalProperties);
|
||||||
|
} else {
|
||||||
|
output = z.record(z.any());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (its.an.anyOf(objectSchema)) {
|
||||||
|
output = output.and(
|
||||||
|
parseAnyOf(
|
||||||
|
{
|
||||||
|
...objectSchema,
|
||||||
|
anyOf: objectSchema.anyOf.map((x) =>
|
||||||
|
typeof x === 'object' &&
|
||||||
|
!x.type &&
|
||||||
|
(x.properties ?? x.additionalProperties ?? x.patternProperties)
|
||||||
|
? { ...x, type: 'object' }
|
||||||
|
: x,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
refs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (its.a.oneOf(objectSchema)) {
|
||||||
|
output = output.and(
|
||||||
|
parseOneOf(
|
||||||
|
{
|
||||||
|
...objectSchema,
|
||||||
|
oneOf: objectSchema.oneOf.map((x) =>
|
||||||
|
typeof x === 'object' &&
|
||||||
|
!x.type &&
|
||||||
|
(x.properties ?? x.additionalProperties ?? x.patternProperties)
|
||||||
|
? { ...x, type: 'object' }
|
||||||
|
: x,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
refs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (its.an.allOf(objectSchema)) {
|
||||||
|
output = output.and(
|
||||||
|
parseAllOf(
|
||||||
|
{
|
||||||
|
...objectSchema,
|
||||||
|
allOf: objectSchema.allOf.map((x) =>
|
||||||
|
typeof x === 'object' &&
|
||||||
|
!x.type &&
|
||||||
|
(x.properties ?? x.additionalProperties ?? x.patternProperties)
|
||||||
|
? { ...x, type: 'object' }
|
||||||
|
: x,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
refs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
41
packages/@n8n/json-schema-to-zod/src/parsers/parse-one-of.ts
Normal file
41
packages/@n8n/json-schema-to-zod/src/parsers/parse-one-of.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { parseSchema } from './parse-schema';
|
||||||
|
import type { JsonSchemaObject, JsonSchema, Refs } from '../types';
|
||||||
|
|
||||||
|
export const parseOneOf = (jsonSchema: JsonSchemaObject & { oneOf: JsonSchema[] }, refs: Refs) => {
|
||||||
|
if (!jsonSchema.oneOf.length) {
|
||||||
|
return z.any();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonSchema.oneOf.length === 1) {
|
||||||
|
return parseSchema(jsonSchema.oneOf[0], {
|
||||||
|
...refs,
|
||||||
|
path: [...refs.path, 'oneOf', 0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return z.any().superRefine((x, ctx) => {
|
||||||
|
const schemas = jsonSchema.oneOf.map((schema, i) =>
|
||||||
|
parseSchema(schema, {
|
||||||
|
...refs,
|
||||||
|
path: [...refs.path, 'oneOf', i],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const unionErrors = schemas.reduce<z.ZodError[]>(
|
||||||
|
(errors, schema) =>
|
||||||
|
((result) => (result.error ? [...errors, result.error] : errors))(schema.safeParse(x)),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (schemas.length - unionErrors.length !== 1) {
|
||||||
|
ctx.addIssue({
|
||||||
|
path: ctx.path,
|
||||||
|
code: 'invalid_union',
|
||||||
|
unionErrors,
|
||||||
|
message: 'Invalid input: Should pass single schema',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
130
packages/@n8n/json-schema-to-zod/src/parsers/parse-schema.ts
Normal file
130
packages/@n8n/json-schema-to-zod/src/parsers/parse-schema.ts
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
import { parseAllOf } from './parse-all-of';
|
||||||
|
import { parseAnyOf } from './parse-any-of';
|
||||||
|
import { parseArray } from './parse-array';
|
||||||
|
import { parseBoolean } from './parse-boolean';
|
||||||
|
import { parseConst } from './parse-const';
|
||||||
|
import { parseDefault } from './parse-default';
|
||||||
|
import { parseEnum } from './parse-enum';
|
||||||
|
import { parseIfThenElse } from './parse-if-then-else';
|
||||||
|
import { parseMultipleType } from './parse-multiple-type';
|
||||||
|
import { parseNot } from './parse-not';
|
||||||
|
import { parseNull } from './parse-null';
|
||||||
|
import { parseNullable } from './parse-nullable';
|
||||||
|
import { parseNumber } from './parse-number';
|
||||||
|
import { parseObject } from './parse-object';
|
||||||
|
import { parseOneOf } from './parse-one-of';
|
||||||
|
import { parseString } from './parse-string';
|
||||||
|
import type { ParserSelector, Refs, JsonSchemaObject, JsonSchema } from '../types';
|
||||||
|
import { its } from '../utils/its';
|
||||||
|
|
||||||
|
const addDescribes = (jsonSchema: JsonSchemaObject, zodSchema: z.ZodTypeAny): z.ZodTypeAny => {
|
||||||
|
if (jsonSchema.description) {
|
||||||
|
zodSchema = zodSchema.describe(jsonSchema.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
return zodSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDefaults = (jsonSchema: JsonSchemaObject, zodSchema: z.ZodTypeAny): z.ZodTypeAny => {
|
||||||
|
if (jsonSchema.default !== undefined) {
|
||||||
|
zodSchema = zodSchema.default(jsonSchema.default);
|
||||||
|
}
|
||||||
|
|
||||||
|
return zodSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addAnnotations = (jsonSchema: JsonSchemaObject, zodSchema: z.ZodTypeAny): z.ZodTypeAny => {
|
||||||
|
if (jsonSchema.readOnly) {
|
||||||
|
zodSchema = zodSchema.readonly();
|
||||||
|
}
|
||||||
|
|
||||||
|
return zodSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectParser: ParserSelector = (schema, refs) => {
|
||||||
|
if (its.a.nullable(schema)) {
|
||||||
|
return parseNullable(schema, refs);
|
||||||
|
} else if (its.an.object(schema)) {
|
||||||
|
return parseObject(schema, refs);
|
||||||
|
} else if (its.an.array(schema)) {
|
||||||
|
return parseArray(schema, refs);
|
||||||
|
} else if (its.an.anyOf(schema)) {
|
||||||
|
return parseAnyOf(schema, refs);
|
||||||
|
} else if (its.an.allOf(schema)) {
|
||||||
|
return parseAllOf(schema, refs);
|
||||||
|
} else if (its.a.oneOf(schema)) {
|
||||||
|
return parseOneOf(schema, refs);
|
||||||
|
} else if (its.a.not(schema)) {
|
||||||
|
return parseNot(schema, refs);
|
||||||
|
} else if (its.an.enum(schema)) {
|
||||||
|
return parseEnum(schema); //<-- needs to come before primitives
|
||||||
|
} else if (its.a.const(schema)) {
|
||||||
|
return parseConst(schema);
|
||||||
|
} else if (its.a.multipleType(schema)) {
|
||||||
|
return parseMultipleType(schema, refs);
|
||||||
|
} else if (its.a.primitive(schema, 'string')) {
|
||||||
|
return parseString(schema);
|
||||||
|
} else if (its.a.primitive(schema, 'number') || its.a.primitive(schema, 'integer')) {
|
||||||
|
return parseNumber(schema);
|
||||||
|
} else if (its.a.primitive(schema, 'boolean')) {
|
||||||
|
return parseBoolean(schema);
|
||||||
|
} else if (its.a.primitive(schema, 'null')) {
|
||||||
|
return parseNull(schema);
|
||||||
|
} else if (its.a.conditional(schema)) {
|
||||||
|
return parseIfThenElse(schema, refs);
|
||||||
|
} else {
|
||||||
|
return parseDefault(schema);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseSchema = (
|
||||||
|
jsonSchema: JsonSchema,
|
||||||
|
refs: Refs = { seen: new Map(), path: [] },
|
||||||
|
blockMeta?: boolean,
|
||||||
|
): z.ZodTypeAny => {
|
||||||
|
if (typeof jsonSchema !== 'object') return jsonSchema ? z.any() : z.never();
|
||||||
|
|
||||||
|
if (refs.parserOverride) {
|
||||||
|
const custom = refs.parserOverride(jsonSchema, refs);
|
||||||
|
|
||||||
|
if (custom instanceof z.ZodType) {
|
||||||
|
return custom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let seen = refs.seen.get(jsonSchema);
|
||||||
|
|
||||||
|
if (seen) {
|
||||||
|
if (seen.r !== undefined) {
|
||||||
|
return seen.r;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refs.depth === undefined || seen.n >= refs.depth) {
|
||||||
|
return z.any();
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.n += 1;
|
||||||
|
} else {
|
||||||
|
seen = { r: undefined, n: 0 };
|
||||||
|
refs.seen.set(jsonSchema, seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedZodSchema = selectParser(jsonSchema, refs);
|
||||||
|
if (!blockMeta) {
|
||||||
|
if (!refs.withoutDescribes) {
|
||||||
|
parsedZodSchema = addDescribes(jsonSchema, parsedZodSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!refs.withoutDefaults) {
|
||||||
|
parsedZodSchema = addDefaults(jsonSchema, parsedZodSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedZodSchema = addAnnotations(jsonSchema, parsedZodSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.r = parsedZodSchema;
|
||||||
|
|
||||||
|
return parsedZodSchema;
|
||||||
|
};
|
58
packages/@n8n/json-schema-to-zod/src/parsers/parse-string.ts
Normal file
58
packages/@n8n/json-schema-to-zod/src/parsers/parse-string.ts
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { JsonSchemaObject } from '../types';
|
||||||
|
import { extendSchemaWithMessage } from '../utils/extend-schema';
|
||||||
|
|
||||||
|
export const parseString = (jsonSchema: JsonSchemaObject & { type: 'string' }) => {
|
||||||
|
let zodSchema = z.string();
|
||||||
|
|
||||||
|
zodSchema = extendSchemaWithMessage(zodSchema, jsonSchema, 'format', (zs, format, errorMsg) => {
|
||||||
|
switch (format) {
|
||||||
|
case 'email':
|
||||||
|
return zs.email(errorMsg);
|
||||||
|
case 'ip':
|
||||||
|
return zs.ip(errorMsg);
|
||||||
|
case 'ipv4':
|
||||||
|
return zs.ip({ version: 'v4', message: errorMsg });
|
||||||
|
case 'ipv6':
|
||||||
|
return zs.ip({ version: 'v6', message: errorMsg });
|
||||||
|
case 'uri':
|
||||||
|
return zs.url(errorMsg);
|
||||||
|
case 'uuid':
|
||||||
|
return zs.uuid(errorMsg);
|
||||||
|
case 'date-time':
|
||||||
|
return zs.datetime({ offset: true, message: errorMsg });
|
||||||
|
case 'time':
|
||||||
|
return zs.time(errorMsg);
|
||||||
|
case 'date':
|
||||||
|
return zs.date(errorMsg);
|
||||||
|
case 'binary':
|
||||||
|
return zs.base64(errorMsg);
|
||||||
|
case 'duration':
|
||||||
|
return zs.duration(errorMsg);
|
||||||
|
default:
|
||||||
|
return zs;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
zodSchema = extendSchemaWithMessage(zodSchema, jsonSchema, 'contentEncoding', (zs, _, errorMsg) =>
|
||||||
|
zs.base64(errorMsg),
|
||||||
|
);
|
||||||
|
zodSchema = extendSchemaWithMessage(zodSchema, jsonSchema, 'pattern', (zs, pattern, errorMsg) =>
|
||||||
|
zs.regex(new RegExp(pattern), errorMsg),
|
||||||
|
);
|
||||||
|
zodSchema = extendSchemaWithMessage(
|
||||||
|
zodSchema,
|
||||||
|
jsonSchema,
|
||||||
|
'minLength',
|
||||||
|
(zs, minLength, errorMsg) => zs.min(minLength, errorMsg),
|
||||||
|
);
|
||||||
|
zodSchema = extendSchemaWithMessage(
|
||||||
|
zodSchema,
|
||||||
|
jsonSchema,
|
||||||
|
'maxLength',
|
||||||
|
(zs, maxLength, errorMsg) => zs.max(maxLength, errorMsg),
|
||||||
|
);
|
||||||
|
|
||||||
|
return zodSchema;
|
||||||
|
};
|
82
packages/@n8n/json-schema-to-zod/src/types.ts
Normal file
82
packages/@n8n/json-schema-to-zod/src/types.ts
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import type { ZodTypeAny } from 'zod';
|
||||||
|
|
||||||
|
export type Serializable =
|
||||||
|
| { [key: string]: Serializable }
|
||||||
|
| Serializable[]
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export type JsonSchema = JsonSchemaObject | boolean;
|
||||||
|
export type JsonSchemaObject = {
|
||||||
|
// left permissive by design
|
||||||
|
type?: string | string[];
|
||||||
|
|
||||||
|
// object
|
||||||
|
properties?: { [key: string]: JsonSchema };
|
||||||
|
additionalProperties?: JsonSchema;
|
||||||
|
unevaluatedProperties?: JsonSchema;
|
||||||
|
patternProperties?: { [key: string]: JsonSchema };
|
||||||
|
minProperties?: number;
|
||||||
|
maxProperties?: number;
|
||||||
|
required?: string[] | boolean;
|
||||||
|
propertyNames?: JsonSchema;
|
||||||
|
|
||||||
|
// array
|
||||||
|
items?: JsonSchema | JsonSchema[];
|
||||||
|
additionalItems?: JsonSchema;
|
||||||
|
minItems?: number;
|
||||||
|
maxItems?: number;
|
||||||
|
uniqueItems?: boolean;
|
||||||
|
|
||||||
|
// string
|
||||||
|
minLength?: number;
|
||||||
|
maxLength?: number;
|
||||||
|
pattern?: string;
|
||||||
|
format?: string;
|
||||||
|
|
||||||
|
// number
|
||||||
|
minimum?: number;
|
||||||
|
maximum?: number;
|
||||||
|
exclusiveMinimum?: number | boolean;
|
||||||
|
exclusiveMaximum?: number | boolean;
|
||||||
|
multipleOf?: number;
|
||||||
|
|
||||||
|
// unions
|
||||||
|
anyOf?: JsonSchema[];
|
||||||
|
allOf?: JsonSchema[];
|
||||||
|
oneOf?: JsonSchema[];
|
||||||
|
|
||||||
|
if?: JsonSchema;
|
||||||
|
then?: JsonSchema;
|
||||||
|
else?: JsonSchema;
|
||||||
|
|
||||||
|
// shared
|
||||||
|
const?: Serializable;
|
||||||
|
enum?: Serializable[];
|
||||||
|
|
||||||
|
errorMessage?: { [key: string]: string | undefined };
|
||||||
|
|
||||||
|
description?: string;
|
||||||
|
default?: Serializable;
|
||||||
|
readOnly?: boolean;
|
||||||
|
not?: JsonSchema;
|
||||||
|
contentEncoding?: string;
|
||||||
|
nullable?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParserSelector = (schema: JsonSchemaObject, refs: Refs) => ZodTypeAny;
|
||||||
|
export type ParserOverride = (schema: JsonSchemaObject, refs: Refs) => ZodTypeAny | undefined;
|
||||||
|
|
||||||
|
export type JsonSchemaToZodOptions = {
|
||||||
|
withoutDefaults?: boolean;
|
||||||
|
withoutDescribes?: boolean;
|
||||||
|
parserOverride?: ParserOverride;
|
||||||
|
depth?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Refs = JsonSchemaToZodOptions & {
|
||||||
|
path: Array<string | number>;
|
||||||
|
seen: Map<object | boolean, { n: number; r: ZodTypeAny | undefined }>;
|
||||||
|
};
|
23
packages/@n8n/json-schema-to-zod/src/utils/extend-schema.ts
Normal file
23
packages/@n8n/json-schema-to-zod/src/utils/extend-schema.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import type { JsonSchemaObject } from '../types';
|
||||||
|
|
||||||
|
export function extendSchemaWithMessage<
|
||||||
|
TZod extends z.ZodTypeAny,
|
||||||
|
TJson extends JsonSchemaObject,
|
||||||
|
TKey extends keyof TJson,
|
||||||
|
>(
|
||||||
|
zodSchema: TZod,
|
||||||
|
jsonSchema: TJson,
|
||||||
|
key: TKey,
|
||||||
|
extend: (zodSchema: TZod, value: NonNullable<TJson[TKey]>, errorMessage?: string) => TZod,
|
||||||
|
) {
|
||||||
|
const value = jsonSchema[key];
|
||||||
|
|
||||||
|
if (value !== undefined) {
|
||||||
|
const errorMessage = jsonSchema.errorMessage?.[key as string];
|
||||||
|
return extend(zodSchema, value as NonNullable<TJson[TKey]>, errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return zodSchema;
|
||||||
|
}
|
3
packages/@n8n/json-schema-to-zod/src/utils/half.ts
Normal file
3
packages/@n8n/json-schema-to-zod/src/utils/half.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export const half = <T>(arr: T[]): [T[], T[]] => {
|
||||||
|
return [arr.slice(0, arr.length / 2), arr.slice(arr.length / 2)];
|
||||||
|
};
|
57
packages/@n8n/json-schema-to-zod/src/utils/its.ts
Normal file
57
packages/@n8n/json-schema-to-zod/src/utils/its.ts
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import type { JsonSchema, JsonSchemaObject, Serializable } from '../types';
|
||||||
|
|
||||||
|
export const its = {
|
||||||
|
an: {
|
||||||
|
object: (x: JsonSchemaObject): x is JsonSchemaObject & { type: 'object' } =>
|
||||||
|
x.type === 'object',
|
||||||
|
array: (x: JsonSchemaObject): x is JsonSchemaObject & { type: 'array' } => x.type === 'array',
|
||||||
|
anyOf: (
|
||||||
|
x: JsonSchemaObject,
|
||||||
|
): x is JsonSchemaObject & {
|
||||||
|
anyOf: JsonSchema[];
|
||||||
|
} => x.anyOf !== undefined,
|
||||||
|
allOf: (
|
||||||
|
x: JsonSchemaObject,
|
||||||
|
): x is JsonSchemaObject & {
|
||||||
|
allOf: JsonSchema[];
|
||||||
|
} => x.allOf !== undefined,
|
||||||
|
enum: (
|
||||||
|
x: JsonSchemaObject,
|
||||||
|
): x is JsonSchemaObject & {
|
||||||
|
enum: Serializable | Serializable[];
|
||||||
|
} => x.enum !== undefined,
|
||||||
|
},
|
||||||
|
a: {
|
||||||
|
nullable: (x: JsonSchemaObject): x is JsonSchemaObject & { nullable: true } =>
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
|
||||||
|
(x as any).nullable === true,
|
||||||
|
multipleType: (x: JsonSchemaObject): x is JsonSchemaObject & { type: string[] } =>
|
||||||
|
Array.isArray(x.type),
|
||||||
|
not: (
|
||||||
|
x: JsonSchemaObject,
|
||||||
|
): x is JsonSchemaObject & {
|
||||||
|
not: JsonSchema;
|
||||||
|
} => x.not !== undefined,
|
||||||
|
const: (
|
||||||
|
x: JsonSchemaObject,
|
||||||
|
): x is JsonSchemaObject & {
|
||||||
|
const: Serializable;
|
||||||
|
} => x.const !== undefined,
|
||||||
|
primitive: <T extends 'string' | 'number' | 'integer' | 'boolean' | 'null'>(
|
||||||
|
x: JsonSchemaObject,
|
||||||
|
p: T,
|
||||||
|
): x is JsonSchemaObject & { type: T } => x.type === p,
|
||||||
|
conditional: (
|
||||||
|
x: JsonSchemaObject,
|
||||||
|
): x is JsonSchemaObject & {
|
||||||
|
if: JsonSchema;
|
||||||
|
then: JsonSchema;
|
||||||
|
else: JsonSchema;
|
||||||
|
} => Boolean('if' in x && x.if && 'then' in x && 'else' in x && x.then && x.else),
|
||||||
|
oneOf: (
|
||||||
|
x: JsonSchemaObject,
|
||||||
|
): x is JsonSchemaObject & {
|
||||||
|
oneOf: JsonSchema[];
|
||||||
|
} => x.oneOf !== undefined,
|
||||||
|
},
|
||||||
|
};
|
8
packages/@n8n/json-schema-to-zod/src/utils/omit.ts
Normal file
8
packages/@n8n/json-schema-to-zod/src/utils/omit.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
export const omit = <T extends object, K extends keyof T>(obj: T, ...keys: K[]): Omit<T, K> =>
|
||||||
|
Object.keys(obj).reduce((acc: Record<string, unknown>, key) => {
|
||||||
|
if (!keys.includes(key as K)) {
|
||||||
|
acc[key] = obj[key as K];
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {}) as Omit<T, K>;
|
143
packages/@n8n/json-schema-to-zod/test/all.json
Normal file
143
packages/@n8n/json-schema-to-zod/test/all.json
Normal file
|
@ -0,0 +1,143 @@
|
||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema",
|
||||||
|
"properties": {
|
||||||
|
"allOf": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"anyOf": {
|
||||||
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"oneOf": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"array": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"minItems": 2,
|
||||||
|
"maxItems": 3
|
||||||
|
},
|
||||||
|
"tuple": {
|
||||||
|
"type": "array",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"minItems": 2,
|
||||||
|
"maxItems": 3
|
||||||
|
},
|
||||||
|
"const": {
|
||||||
|
"const": "xbox"
|
||||||
|
},
|
||||||
|
"enum": {
|
||||||
|
"enum": ["ps4", "ps5"]
|
||||||
|
},
|
||||||
|
"ifThenElse": {
|
||||||
|
"if": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"then": {
|
||||||
|
"const": "x"
|
||||||
|
},
|
||||||
|
"else": {
|
||||||
|
"enum": [1, 2, 3]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"null": {
|
||||||
|
"type": "null"
|
||||||
|
},
|
||||||
|
"multiple": {
|
||||||
|
"type": ["array", "boolean"]
|
||||||
|
},
|
||||||
|
"objAdditionalTrue": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": true
|
||||||
|
},
|
||||||
|
"objAdditionalFalse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"objAdditionalNumber": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"x": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"objAdditionalOnly": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"patternProps": {
|
||||||
|
"type": "object",
|
||||||
|
"patternProperties": {
|
||||||
|
"^x": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"^y": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"z": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
16
packages/@n8n/json-schema-to-zod/test/extend-expect.ts
Normal file
16
packages/@n8n/json-schema-to-zod/test/extend-expect.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
expect.extend({
|
||||||
|
toMatchZod(this: jest.MatcherContext, actual: z.ZodTypeAny, expected: z.ZodTypeAny) {
|
||||||
|
const actualSerialized = JSON.stringify(actual._def, null, 2);
|
||||||
|
const expectedSerialized = JSON.stringify(expected._def, null, 2);
|
||||||
|
const pass = this.equals(actualSerialized, expectedSerialized);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pass,
|
||||||
|
message: pass
|
||||||
|
? () => `Expected ${actualSerialized} not to match ${expectedSerialized}`
|
||||||
|
: () => `Expected ${actualSerialized} to match ${expectedSerialized}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
5
packages/@n8n/json-schema-to-zod/test/jest.d.ts
vendored
Normal file
5
packages/@n8n/json-schema-to-zod/test/jest.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
namespace jest {
|
||||||
|
interface Matchers<R, T> {
|
||||||
|
toMatchZod(expected: unknown): T;
|
||||||
|
}
|
||||||
|
}
|
106
packages/@n8n/json-schema-to-zod/test/json-schema-to-zod.test.ts
Normal file
106
packages/@n8n/json-schema-to-zod/test/json-schema-to-zod.test.ts
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
import type { JSONSchema4, JSONSchema6Definition, JSONSchema7Definition } from 'json-schema';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { jsonSchemaToZod } from '../src';
|
||||||
|
|
||||||
|
describe('jsonSchemaToZod', () => {
|
||||||
|
test('should accept json schema 7 and 4', () => {
|
||||||
|
const schema = { type: 'string' } as unknown;
|
||||||
|
|
||||||
|
expect(jsonSchemaToZod(schema as JSONSchema4));
|
||||||
|
expect(jsonSchemaToZod(schema as JSONSchema6Definition));
|
||||||
|
expect(jsonSchemaToZod(schema as JSONSchema7Definition));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can exclude defaults', () => {
|
||||||
|
expect(
|
||||||
|
jsonSchemaToZod(
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
default: 'foo',
|
||||||
|
},
|
||||||
|
{ withoutDefaults: true },
|
||||||
|
),
|
||||||
|
).toMatchZod(z.string());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should include describes', () => {
|
||||||
|
expect(
|
||||||
|
jsonSchemaToZod({
|
||||||
|
type: 'string',
|
||||||
|
description: 'foo',
|
||||||
|
}),
|
||||||
|
).toMatchZod(z.string().describe('foo'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can exclude describes', () => {
|
||||||
|
expect(
|
||||||
|
jsonSchemaToZod(
|
||||||
|
{
|
||||||
|
type: 'string',
|
||||||
|
description: 'foo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
withoutDescribes: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toMatchZod(z.string());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('will remove optionality if default is present', () => {
|
||||||
|
expect(
|
||||||
|
jsonSchemaToZod({
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
prop: {
|
||||||
|
type: 'string',
|
||||||
|
default: 'def',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toMatchZod(z.object({ prop: z.string().default('def') }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('will handle falsy defaults', () => {
|
||||||
|
expect(
|
||||||
|
jsonSchemaToZod({
|
||||||
|
type: 'boolean',
|
||||||
|
default: false,
|
||||||
|
}),
|
||||||
|
).toMatchZod(z.boolean().default(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('will ignore undefined as default', () => {
|
||||||
|
expect(
|
||||||
|
jsonSchemaToZod({
|
||||||
|
type: 'null',
|
||||||
|
default: undefined,
|
||||||
|
}),
|
||||||
|
).toMatchZod(z.null());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be possible to define a custom parser', () => {
|
||||||
|
expect(
|
||||||
|
jsonSchemaToZod(
|
||||||
|
{
|
||||||
|
allOf: [{ type: 'string' }, { type: 'number' }, { type: 'boolean', description: 'foo' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parserOverride: (schema, refs) => {
|
||||||
|
if (
|
||||||
|
refs.path.length === 2 &&
|
||||||
|
refs.path[0] === 'allOf' &&
|
||||||
|
refs.path[1] === 2 &&
|
||||||
|
schema.type === 'boolean' &&
|
||||||
|
schema.description === 'foo'
|
||||||
|
) {
|
||||||
|
return z.null();
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toMatchZod(z.intersection(z.string(), z.intersection(z.number(), z.null())));
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { parseAllOf } from '../../src/parsers/parse-all-of';
|
||||||
|
|
||||||
|
describe('parseAllOf', () => {
|
||||||
|
test('should create never if empty', () => {
|
||||||
|
expect(
|
||||||
|
parseAllOf(
|
||||||
|
{
|
||||||
|
allOf: [],
|
||||||
|
},
|
||||||
|
{ path: [], seen: new Map() },
|
||||||
|
),
|
||||||
|
).toMatchZod(z.never());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle true values', () => {
|
||||||
|
expect(
|
||||||
|
parseAllOf(
|
||||||
|
{
|
||||||
|
allOf: [{ type: 'string' }, true],
|
||||||
|
},
|
||||||
|
{ path: [], seen: new Map() },
|
||||||
|
),
|
||||||
|
).toMatchZod(z.intersection(z.string(), z.any()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle false values', () => {
|
||||||
|
expect(
|
||||||
|
parseAllOf(
|
||||||
|
{
|
||||||
|
allOf: [{ type: 'string' }, false],
|
||||||
|
},
|
||||||
|
{ path: [], seen: new Map() },
|
||||||
|
),
|
||||||
|
).toMatchZod(
|
||||||
|
z.intersection(
|
||||||
|
z.string(),
|
||||||
|
z
|
||||||
|
.any()
|
||||||
|
.refine(
|
||||||
|
(value) => !z.any().safeParse(value).success,
|
||||||
|
'Invalid input: Should NOT be valid against schema',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue