Merge remote-tracking branch 'origin/master' into pay-1852-public-api-delete-users-from-project

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2025-01-09 20:37:43 +01:00
commit e9d96f55e7
No known key found for this signature in database
2273 changed files with 50048 additions and 10639 deletions

View file

@ -1,6 +1,8 @@
name: Chromatic
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
pull_request_review:
types: [submitted]
@ -70,7 +72,7 @@ jobs:
exitZeroOnChanges: false
- name: Success comment
if: steps.chromatic_tests.outcome == 'success'
if: steps.chromatic_tests.outcome == 'success' && github.ref != 'refs/heads/master'
uses: peter-evans/create-or-update-comment@v4.0.0
with:
issue-number: ${{ github.event.pull_request.number }}
@ -80,7 +82,7 @@ jobs:
:white_check_mark: No visual regressions found.
- name: Fail comment
if: steps.chromatic_tests.outcome != 'success'
if: steps.chromatic_tests.outcome != 'success' && github.ref != 'refs/heads/master'
uses: peter-evans/create-or-update-comment@v4.0.0
with:
issue-number: ${{ github.event.pull_request.number }}

View file

@ -47,6 +47,7 @@ jobs:
nodeVersion: ${{ matrix.node-version }}
cacheKey: ${{ github.sha }}-base:build
collectCoverage: ${{ matrix.node-version == '20.x' }}
ignoreTurboCache: ${{ matrix.node-version == '20.x' }}
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

View file

@ -106,7 +106,7 @@ jobs:
- name: Test MariaDB
working-directory: packages/cli
run: pnpm test:mariadb --testTimeout 20000
run: pnpm test:mariadb --testTimeout 30000
postgres:
name: Postgres

View file

@ -17,7 +17,9 @@ jobs:
build:
name: Install & Build
runs-on: ubuntu-latest
if: github.event_name != 'pull_request_review' || startsWith(github.event.pull_request.base.ref, 'release/')
if: |
(github.event_name != 'pull_request_review' || startsWith(github.event.pull_request.base.ref, 'release/')) &&
!contains(github.event.pull_request.labels.*.name, 'community')
steps:
- uses: actions/checkout@v4.1.1
- run: corepack enable

View file

@ -22,6 +22,10 @@ on:
required: false
default: false
type: boolean
ignoreTurboCache:
required: false
default: false
type: boolean
secrets:
CODECOV_TOKEN:
description: 'Codecov upload token.'
@ -32,6 +36,7 @@ jobs:
name: Unit tests
runs-on: ubuntu-latest
env:
TURBO_FORCE: ${{ inputs.ignoreTurboCache }}
COVERAGE_ENABLED: ${{ inputs.collectCoverage }}
steps:
- uses: actions/checkout@v4.1.1
@ -49,7 +54,6 @@ jobs:
run: pnpm install --frozen-lockfile
- name: Setup build cache
if: inputs.collectCoverage != true
uses: rharkor/caching-for-turbo@v1.5
- name: Build
@ -74,6 +78,6 @@ jobs:
- name: Upload coverage to Codecov
if: inputs.collectCoverage
uses: codecov/codecov-action@v4.5.0
uses: codecov/codecov-action@v5.1.2
with:
token: ${{ secrets.CODECOV_TOKEN }}

View file

@ -1,3 +1,120 @@
# [1.74.0](https://github.com/n8n-io/n8n/compare/n8n@1.73.0...n8n@1.74.0) (2025-01-08)
### Bug Fixes
* **core:** Align concurrency and timeout defaults between instance and runner ([#12503](https://github.com/n8n-io/n8n/issues/12503)) ([9953477](https://github.com/n8n-io/n8n/commit/9953477450c28ec2d211e55aadb825dbae2ee4d6))
* **core:** Allow `index` as top-level item key for Code node ([#12469](https://github.com/n8n-io/n8n/issues/12469)) ([1b91000](https://github.com/n8n-io/n8n/commit/1b9100032fc9f8c33e263c8299e04054105da384))
* **core:** Don't fail task runner task if logging fails ([#12401](https://github.com/n8n-io/n8n/issues/12401)) ([0860fbe](https://github.com/n8n-io/n8n/commit/0860fbe97108edc21bc01dec3b6ef13e60e728d4))
* **core:** Ensure tasks timeout even if they don't receive settings ([#12431](https://github.com/n8n-io/n8n/issues/12431)) ([b194026](https://github.com/n8n-io/n8n/commit/b1940268e6110ed3d8949318a5252ac6563d624f))
* **core:** Fix execution cancellation issues in scaling mode ([#12343](https://github.com/n8n-io/n8n/issues/12343)) ([e26b406](https://github.com/n8n-io/n8n/commit/e26b406665e20761279c4e315d04501350427de5))
* **core:** Fix manually running a pinned trigger with offloading enabled ([#12491](https://github.com/n8n-io/n8n/issues/12491)) ([be2dcff](https://github.com/n8n-io/n8n/commit/be2dcffc9487973d3e287dd4f6956dbba03757e3))
* **core:** Fix task runner sending too many offers ([#12415](https://github.com/n8n-io/n8n/issues/12415)) ([4498e35](https://github.com/n8n-io/n8n/commit/4498e3519276020d3eb01752b5ce0d8ecfbf5fa4))
* **core:** Increase default concurrency and timeout in task runners ([#12496](https://github.com/n8n-io/n8n/issues/12496)) ([4182095](https://github.com/n8n-io/n8n/commit/4182095af1c02832af2523f31e9cb85d9a345e60))
* **core:** Prevent `__default__` jobs in scaling mode ([#12402](https://github.com/n8n-io/n8n/issues/12402)) ([072664b](https://github.com/n8n-io/n8n/commit/072664b40e06943e0b8ff44287730f2ca569646f))
* **core:** Register workflows as active only after all of the triggers and pollers setup successfully ([#12244](https://github.com/n8n-io/n8n/issues/12244)) ([f924f2a](https://github.com/n8n-io/n8n/commit/f924f2a6d736e33ab5fc12cbac6cba27340839db))
* **core:** Return unredacted credentials from `GET credentials/:id` ([#12447](https://github.com/n8n-io/n8n/issues/12447)) ([ecabe34](https://github.com/n8n-io/n8n/commit/ecabe34705bbbba07613ba14760449ef38e1b31f))
* **core:** Use rate limiter for task runner endpoints ([#12486](https://github.com/n8n-io/n8n/issues/12486)) ([491cb60](https://github.com/n8n-io/n8n/commit/491cb605e3c93d7a261bb0cef0d38f2ddc3affe8))
* **editor:** Allow zooming when panning keycode is pressed on new canvas ([#12327](https://github.com/n8n-io/n8n/issues/12327)) ([983e87a](https://github.com/n8n-io/n8n/commit/983e87a9b0c83d35354ce4df34096f47173d0ea7))
* **editor:** Consistent protected environment styling and messaging ([#12374](https://github.com/n8n-io/n8n/issues/12374)) ([6891cef](https://github.com/n8n-io/n8n/commit/6891cefa6d0359f85a596829b6055a13529fb1fb))
* **editor:** First project button tweaks border and copy ([#12376](https://github.com/n8n-io/n8n/issues/12376)) ([e234756](https://github.com/n8n-io/n8n/commit/e234756457d3c3526531ced4471bf9e69a79fa55))
* **editor:** Fix Multi option parameter expression when the value is an array ([#12430](https://github.com/n8n-io/n8n/issues/12430)) ([452a7bf](https://github.com/n8n-io/n8n/commit/452a7bfe2c1e786c46a3ed99de007b0cf3f28d15))
* **editor:** Improve configurable nodes design on new canvas ([#12317](https://github.com/n8n-io/n8n/issues/12317)) ([0ecce10](https://github.com/n8n-io/n8n/commit/0ecce10faf60ae44d11007d45e87766b678d3a84))
* **editor:** Minor styling improvements in project settings page ([#12405](https://github.com/n8n-io/n8n/issues/12405)) ([09ddce0](https://github.com/n8n-io/n8n/commit/09ddce05800f426d33489ae28c416bb6aab2fd91))
* **editor:** Never show Pinned Data Callout for Input Panel ([#12446](https://github.com/n8n-io/n8n/issues/12446)) ([1d5c9bd](https://github.com/n8n-io/n8n/commit/1d5c9bd466becf8aa245a1e8d0b799616d18914a))
* **editor:** Nodes' icon color in dark mode ([#12279](https://github.com/n8n-io/n8n/issues/12279)) ([01b781a](https://github.com/n8n-io/n8n/commit/01b781a10828ca2c4cf32762373ad40904c02d2c))
* **editor:** Only ignore managed credentials in the HTTP node ([#12417](https://github.com/n8n-io/n8n/issues/12417)) ([6b46657](https://github.com/n8n-io/n8n/commit/6b46657412a1efff35be5083f0ff4c00f9b3e7f9))
* **editor:** Remove primary highlight color from edge being executed on new canvas ([#12307](https://github.com/n8n-io/n8n/issues/12307)) ([50913de](https://github.com/n8n-io/n8n/commit/50913de2651450e18307a833ada57656d8959493))
* **editor:** Render empty string instead of [empty] ([#12448](https://github.com/n8n-io/n8n/issues/12448)) ([2c72047](https://github.com/n8n-io/n8n/commit/2c72047d0b260db5a4b1fd0d7448ab19378e908f))
* **editor:** Show all workflows in the error workflow dropdown in the workflow settings ([#12413](https://github.com/n8n-io/n8n/issues/12413)) ([ccda7f9](https://github.com/n8n-io/n8n/commit/ccda7f9c62e2ba04dbd8a86cfeb5016b56f19c7a))
* **editor:** Unify disabled parameters background color ([#12306](https://github.com/n8n-io/n8n/issues/12306)) ([8c63599](https://github.com/n8n-io/n8n/commit/8c635993bd65c84707938d9564d54c1ae17f1c1f))
* **HTTP Request Node:** Fix typo in hint ([#12439](https://github.com/n8n-io/n8n/issues/12439)) ([b6230b6](https://github.com/n8n-io/n8n/commit/b6230b63f2ed8c7531b53c896f8b033c599e156e))
* **OpenAI Node:** Add quotes to default base URL ([#12312](https://github.com/n8n-io/n8n/issues/12312)) ([2e90eba](https://github.com/n8n-io/n8n/commit/2e90eba47eff81f8b17a305cbc1656f929d622f8))
* **OpenAI Node:** Update node to account for URL in credentials ([#12356](https://github.com/n8n-io/n8n/issues/12356)) ([f78cceb](https://github.com/n8n-io/n8n/commit/f78ccebe514819dca03f5c220274b94fd6d1c73b))
* **Postgres Node:** Account for JSON expressions ([#12012](https://github.com/n8n-io/n8n/issues/12012)) ([06b86af](https://github.com/n8n-io/n8n/commit/06b86af7356b3be0af146c49f9720b24157b9e61))
* **Postgres Node:** Allow passing in arrays to JSON columns for insert ([#12452](https://github.com/n8n-io/n8n/issues/12452)) ([9dd0686](https://github.com/n8n-io/n8n/commit/9dd068632b1542126831baa83cf638ce369b0947))
* **Postgres Node:** Re-use connection pool across executions ([#12346](https://github.com/n8n-io/n8n/issues/12346)) ([2ca37f5](https://github.com/n8n-io/n8n/commit/2ca37f5f7f7f80c50dbc8c87146b8bff510f01c8))
* Run workflow if active and single webhook service has pin data ([#12425](https://github.com/n8n-io/n8n/issues/12425)) ([8053a4a](https://github.com/n8n-io/n8n/commit/8053a4a1763d143da80b9e4e00dcef9b716ce6b2))
* Set correct default for added Resource Mapper boolean fields ([#12344](https://github.com/n8n-io/n8n/issues/12344)) ([b4c77f2](https://github.com/n8n-io/n8n/commit/b4c77f27b66275ddb58138e8d2fe1509265e9652))
* **Supabase Node:** Allow for filtering on the same field multiple times ([#12429](https://github.com/n8n-io/n8n/issues/12429)) ([d7cc789](https://github.com/n8n-io/n8n/commit/d7cc789d79477aff40ff4eca0175c7578aef338a))
* **Zep Vector Store Node:** Cloud vector store integration ([#12353](https://github.com/n8n-io/n8n/issues/12353)) ([2433d6b](https://github.com/n8n-io/n8n/commit/2433d6b7d3dede2595dd5b637ca8bbc1103272b3))
### Features
* (Execute Workflow Node): Inputs for Sub-workflows ([#11830](https://github.com/n8n-io/n8n/issues/11830)) ([#11837](https://github.com/n8n-io/n8n/issues/11837)) ([d411663](https://github.com/n8n-io/n8n/commit/d4116630a638195c7d87e01e2b5c151941636056))
* Add load options to new tool mode for vector stores ([#12462](https://github.com/n8n-io/n8n/issues/12462)) ([3109de6](https://github.com/n8n-io/n8n/commit/3109de6073b237ee3dcc93afb69345586f3b836d))
* Add migration to add `managed` column to credentials table ([#12275](https://github.com/n8n-io/n8n/issues/12275)) ([3cb7081](https://github.com/n8n-io/n8n/commit/3cb70814465e8fa504e909ef36b21b79d4b70b28))
* Allow using Vector Stores directly as Tools ([#12311](https://github.com/n8n-io/n8n/issues/12311)) ([76dded4](https://github.com/n8n-io/n8n/commit/76dded4bea9d26ad84fdbde74d577d244eb4e223))
* **core:** Add endpoint to create free AI credits ([#12362](https://github.com/n8n-io/n8n/issues/12362)) ([ac4e042](https://github.com/n8n-io/n8n/commit/ac4e0422316a4dcd19151dd7d504e2b3cccbc038))
* **core:** Add includeData parameter to `GET /credentials` ([#12220](https://github.com/n8n-io/n8n/issues/12220)) ([f56ad8c](https://github.com/n8n-io/n8n/commit/f56ad8cf49f7cf0665035d2e43bb7ff5b8fd75f3))
* **core:** Comply with `NO_COLOR` in logs ([#12347](https://github.com/n8n-io/n8n/issues/12347)) ([1e60bbc](https://github.com/n8n-io/n8n/commit/1e60bbcf169e8624a97ddde543cdd1d406e5c7ca))
* **core:** Offload manual executions to workers ([#11284](https://github.com/n8n-io/n8n/issues/11284)) ([9432aa0](https://github.com/n8n-io/n8n/commit/9432aa0b00e74faf4651ac673f18e16b7e56e145))
* **editor:** Add free AI credits CTA ([#12365](https://github.com/n8n-io/n8n/issues/12365)) ([f873196](https://github.com/n8n-io/n8n/commit/f8731963f6754386f15c8417c0cc32dba87c481a))
* **editor:** Add support for project icons ([#12349](https://github.com/n8n-io/n8n/issues/12349)) ([9117718](https://github.com/n8n-io/n8n/commit/9117718cc960e2bad5a5db07b10e9e7b561ec5e4))
* **editor:** Easy AI workflow improvements ([#12400](https://github.com/n8n-io/n8n/issues/12400)) ([8dc691d](https://github.com/n8n-io/n8n/commit/8dc691dc62692f8af143c84032391397adeb790d))
* **editor:** Make workflows, credentials, executions and new canvas usable on mobile and touch devices ([#12372](https://github.com/n8n-io/n8n/issues/12372)) ([06c9473](https://github.com/n8n-io/n8n/commit/06c94732103687705d71c5a1c5bfa993e3df3427))
* **editor:** New Code editor based on the TypeScript language service ([#12285](https://github.com/n8n-io/n8n/issues/12285)) ([52ae02a](https://github.com/n8n-io/n8n/commit/52ae02abaa92e5bbfda58843c8eccc845506fa4b))
* **editor:** Update Sub-Workflow Debugging Copy ([#12483](https://github.com/n8n-io/n8n/issues/12483)) ([04e2928](https://github.com/n8n-io/n8n/commit/04e2928d345f83c202c762e4673cf878b4762f33))
* **Google Vertex Chat Model Node:** Add an option to specify GCP region ([#12300](https://github.com/n8n-io/n8n/issues/12300)) ([30f9c03](https://github.com/n8n-io/n8n/commit/30f9c033db28112e1f97bb55d41b5bfce265cb51))
* **HighLevel Node:** Add support for calendar items ([#10820](https://github.com/n8n-io/n8n/issues/10820)) ([6e189fd](https://github.com/n8n-io/n8n/commit/6e189fda776051e09e90b3d86ecd0d1e80dcc0c6))
* **Microsoft Entra ID Node:** New node ([#11779](https://github.com/n8n-io/n8n/issues/11779)) ([3006ccf](https://github.com/n8n-io/n8n/commit/3006ccf41bb911ba72f087a1479889fbf308c17d))
# [1.73.0](https://github.com/n8n-io/n8n/compare/n8n@1.72.0...n8n@1.73.0) (2024-12-19)
### Bug Fixes
* **core:** Ensure runners do not throw on unsupported console methods ([#12167](https://github.com/n8n-io/n8n/issues/12167)) ([57c6a61](https://github.com/n8n-io/n8n/commit/57c6a6167dd2b30f0082a416daefce994ecad33a))
* **core:** Fix `$getWorkflowStaticData` on task runners ([#12153](https://github.com/n8n-io/n8n/issues/12153)) ([b479f14](https://github.com/n8n-io/n8n/commit/b479f14ef5012551b823bea5d2ffbddedfd50a77))
* **core:** Fix binary data helpers (like `prepareBinaryData`) with task runner ([#12259](https://github.com/n8n-io/n8n/issues/12259)) ([0f1461f](https://github.com/n8n-io/n8n/commit/0f1461f2d5d7ec34236ed7fcec3e2f9ee7eb73c4))
* **core:** Fix race condition in AI tool invocation with multiple items from the parent ([#12169](https://github.com/n8n-io/n8n/issues/12169)) ([dce0c58](https://github.com/n8n-io/n8n/commit/dce0c58f8605c33fc50ec8aa422f0fb5eee07637))
* **core:** Fix serialization of circular json with task runner ([#12288](https://github.com/n8n-io/n8n/issues/12288)) ([a99d726](https://github.com/n8n-io/n8n/commit/a99d726f42d027b64f94eda0d385b597c5d5be2e))
* **core:** Upgrade nanoid to address CVE-2024-55565 ([#12171](https://github.com/n8n-io/n8n/issues/12171)) ([8c0bd02](https://github.com/n8n-io/n8n/commit/8c0bd0200c386b122f495c453ccc97a001e4729c))
* **editor:** Add new create first project CTA ([#12189](https://github.com/n8n-io/n8n/issues/12189)) ([878b419](https://github.com/n8n-io/n8n/commit/878b41904d76eda3ee230f850127b4d56993de24))
* **editor:** Fix canvas ready opacity transition on new canvas ([#12264](https://github.com/n8n-io/n8n/issues/12264)) ([5d33a6b](https://github.com/n8n-io/n8n/commit/5d33a6ba8a2bccea097402fd04c0e2b00e423e76))
* **editor:** Fix rendering of code-blocks in sticky notes ([#12227](https://github.com/n8n-io/n8n/issues/12227)) ([9b59035](https://github.com/n8n-io/n8n/commit/9b5903524b95bd21d5915908780942790cf88d27))
* **editor:** Fix sticky color picker getting covered by nodes on new canvas ([#12263](https://github.com/n8n-io/n8n/issues/12263)) ([27bd3c8](https://github.com/n8n-io/n8n/commit/27bd3c85b3a4ddcf763a543b232069bb108130cf))
* **editor:** Improve commit modal user facing messaging ([#12161](https://github.com/n8n-io/n8n/issues/12161)) ([ad39243](https://github.com/n8n-io/n8n/commit/ad392439826b17bd0b84f981e0958d88f09e7fe9))
* **editor:** Prevent connection line from showing when clicking the plus button of a node ([#12265](https://github.com/n8n-io/n8n/issues/12265)) ([9180b46](https://github.com/n8n-io/n8n/commit/9180b46b52302b203eecf3bb81c3f2132527a1e6))
* **editor:** Prevent stickies from being edited in preview mode in the new canvas ([#12222](https://github.com/n8n-io/n8n/issues/12222)) ([6706dcd](https://github.com/n8n-io/n8n/commit/6706dcdf72d54f33c1cf4956602c3a64a1578826))
* **editor:** Reduce cases for Auto-Add of ChatTrigger for AI Agents ([#12154](https://github.com/n8n-io/n8n/issues/12154)) ([365e82d](https://github.com/n8n-io/n8n/commit/365e82d2008dff2f9c91664ee04d7a78363a8b30))
* **editor:** Remove invalid connections after node handles change ([#12247](https://github.com/n8n-io/n8n/issues/12247)) ([6330bec](https://github.com/n8n-io/n8n/commit/6330bec4db0175b558f2747837323fdbb25b634a))
* **editor:** Set dangerouslyUseHTMLString in composable ([#12280](https://github.com/n8n-io/n8n/issues/12280)) ([6ba91b5](https://github.com/n8n-io/n8n/commit/6ba91b5e1ed197c67146347a6f6e663ecdf3de48))
* **editor:** Set RunData outputIndex based on incoming data ([#12182](https://github.com/n8n-io/n8n/issues/12182)) ([dc4261a](https://github.com/n8n-io/n8n/commit/dc4261ae7eca6cf277404cd514c90fad42f14ae0))
* **editor:** Update the universal create button interaction ([#12105](https://github.com/n8n-io/n8n/issues/12105)) ([5300e0a](https://github.com/n8n-io/n8n/commit/5300e0ac45bf832b3d2957198a49a1c687f3fe1f))
* **Elasticsearch Node:** Fix issue stopping search queries being sent ([#11464](https://github.com/n8n-io/n8n/issues/11464)) ([388a83d](https://github.com/n8n-io/n8n/commit/388a83dfbdc6ac301e4df704666df9f09fb7d0b3))
* **Extract from File Node:** Detect file encoding ([#12081](https://github.com/n8n-io/n8n/issues/12081)) ([92af245](https://github.com/n8n-io/n8n/commit/92af245d1aab5bfad8618fda69b2405f5206875d))
* **Github Node:** Fix fetch of file names with ? character ([#12206](https://github.com/n8n-io/n8n/issues/12206)) ([39462ab](https://github.com/n8n-io/n8n/commit/39462abe1fde7e82b5e5b8f3ceebfcadbfd7c925))
* **Invoice Ninja Node:** Fix actions for bank transactions ([#11511](https://github.com/n8n-io/n8n/issues/11511)) ([80eea49](https://github.com/n8n-io/n8n/commit/80eea49cf0bf9db438eb85af7cd22aeb11fbfed2))
* **Linear Node:** Fix issue with error handling ([#12191](https://github.com/n8n-io/n8n/issues/12191)) ([b8eae5f](https://github.com/n8n-io/n8n/commit/b8eae5f28a7d523195f4715cd8da77b3a884ae4c))
* **MongoDB Node:** Fix checks on projection feature call ([#10563](https://github.com/n8n-io/n8n/issues/10563)) ([58bab46](https://github.com/n8n-io/n8n/commit/58bab461c4c5026b2ca5ea143cbcf98bf3a4ced8))
* **Postgres Node:** Allow users to wrap strings with $$ ([#12034](https://github.com/n8n-io/n8n/issues/12034)) ([0c15e30](https://github.com/n8n-io/n8n/commit/0c15e30778cc5cb10ed368df144d6fbb2504ec70))
* **Redis Node:** Add support for username auth ([#12274](https://github.com/n8n-io/n8n/issues/12274)) ([64c0414](https://github.com/n8n-io/n8n/commit/64c0414ef28acf0f7ec42b4b0bb21cbf2921ebe7))
### Features
* Add solarwinds ipam credentials ([#12005](https://github.com/n8n-io/n8n/issues/12005)) ([882484e](https://github.com/n8n-io/n8n/commit/882484e8ee7d1841d5d600414ca48e9915abcfa8))
* Add SolarWinds Observability node credentials ([#11805](https://github.com/n8n-io/n8n/issues/11805)) ([e8a5db5](https://github.com/n8n-io/n8n/commit/e8a5db5beb572edbb61dd9100b70827ccc4cca58))
* **AI Agent Node:** Update descriptions and titles for Chat Trigger options in AI Agents and Memory ([#12155](https://github.com/n8n-io/n8n/issues/12155)) ([07a6ae1](https://github.com/n8n-io/n8n/commit/07a6ae11b3291c1805553d55ba089fe8dd919fd8))
* **API:** Exclude pinned data from workflows ([#12261](https://github.com/n8n-io/n8n/issues/12261)) ([e0dc385](https://github.com/n8n-io/n8n/commit/e0dc385f8bc8ee13fbc5bbf35e07654e52b193e9))
* **editor:** Params pane collection improvements ([#11607](https://github.com/n8n-io/n8n/issues/11607)) ([6e44c71](https://github.com/n8n-io/n8n/commit/6e44c71c9ca82cce20eb55bb9003930bbf66a16c))
* **editor:** Support adding nodes via drag and drop from node creator on new canvas ([#12197](https://github.com/n8n-io/n8n/issues/12197)) ([1bfd9c0](https://github.com/n8n-io/n8n/commit/1bfd9c0e913f3eefc4593f6c344db1ae1f6e4df4))
* **Facebook Graph API Node:** Update node to support API v21.0 ([#12116](https://github.com/n8n-io/n8n/issues/12116)) ([14c33f6](https://github.com/n8n-io/n8n/commit/14c33f666fe92f7173e4f471fb478e629e775c62))
* **Linear Trigger Node:** Add support for admin scope ([#12211](https://github.com/n8n-io/n8n/issues/12211)) ([410ea9a](https://github.com/n8n-io/n8n/commit/410ea9a2ef2e14b5e8e4493e5db66cfc2290d8f6))
* **MailerLite Node:** Update node to support new api ([#11933](https://github.com/n8n-io/n8n/issues/11933)) ([d6b8e65](https://github.com/n8n-io/n8n/commit/d6b8e65abeb411f86538c1630dcce832ee0846a9))
* Send and wait operation - freeText and customForm response types ([#12106](https://github.com/n8n-io/n8n/issues/12106)) ([e98c7f1](https://github.com/n8n-io/n8n/commit/e98c7f160b018243dc88490d46fb1047a4d7fcdc))
### Performance Improvements
* **editor:** SchemaView performance improvement by ≈90% 🚀 ([#12180](https://github.com/n8n-io/n8n/issues/12180)) ([6a58309](https://github.com/n8n-io/n8n/commit/6a5830959f5fb493a4119869b8298d8ed702c84a))
# [1.72.0](https://github.com/n8n-io/n8n/compare/n8n@1.71.0...n8n@1.72.0) (2024-12-11)

View file

@ -19,6 +19,7 @@ Great that you are here and you want to contribute to n8n
- [Actual n8n setup](#actual-n8n-setup)
- [Start](#start)
- [Development cycle](#development-cycle)
- [Community PR Guidelines](#community-pr-guidelines)
- [Test suite](#test-suite)
- [Unit tests](#unit-tests)
- [E2E tests](#e2e-tests)
@ -191,6 +192,51 @@ automatically build your code, restart the backend and refresh the frontend
```
1. Commit code and [create a pull request](https://docs.github.com/en/github/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork)
---
### Community PR Guidelines
#### **1. Change Request/Comment**
Please address the requested changes or provide feedback within 14 days. If there is no response or updates to the pull request during this time, it will be automatically closed. The PR can be reopened once the requested changes are applied.
#### **2. General Requirements**
- **Follow the Style Guide:**
- Ensure your code adheres to n8n's coding standards and conventions (e.g., formatting, naming, indentation). Use linting tools where applicable.
- **TypeScript Compliance:**
- Do not use `ts-ignore` .
- Ensure code adheres to TypeScript rules.
- **Avoid Repetitive Code:**
- Reuse existing components, parameters, and logic wherever possible instead of redefining or duplicating them.
- For nodes: Use the same parameter across multiple operations rather than defining a new parameter for each operation (if applicable).
- **Testing Requirements:**
- PRs **must include tests**:
- Unit tests
- Workflow tests for nodes (example [here](https://github.com/n8n-io/n8n/tree/master/packages/nodes-base/nodes/Switch/V3/test))
- UI tests (if applicable)
- **Typos:**
- Use a spell-checking tool, such as [**Code Spell Checker**](https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker), to avoid typos.
#### **3. PR Specific Requirements**
- **Small PRs Only:**
- Focus on a single feature or fix per PR.
- **Naming Convention:**
- Follow [n8n's PR Title Conventions](https://github.com/n8n-io/n8n/blob/master/.github/pull_request_title_conventions.md#L36).
- **New Nodes:**
- PRs that introduce new nodes will be **auto-closed** unless they are explicitly requested by the n8n team and aligned with an agreed project scope. However, you can still explore [building your own nodes](https://docs.n8n.io/integrations/creating-nodes/) , as n8n offers the flexibility to create your own custom nodes.
- **Typo-Only PRs:**
- Typos are not sufficient justification for a PR and will be rejected.
#### **4. Workflow Summary for Non-Compliant PRs**
- **No Tests:** If tests are not provided, the PR will be auto-closed after **14 days**.
- **Non-Small PRs:** Large or multifaceted PRs will be returned for segmentation.
- **New Nodes/Typo PRs:** Automatically rejected if not aligned with project scope or guidelines.
---
### Test suite
#### Unit tests

View file

@ -3,9 +3,11 @@
Portions of this software are licensed as follows:
- Content of branches other than the main branch (i.e. "master") are not licensed.
- Source code files that contain ".ee." in their filename are NOT licensed under the Sustainable Use License.
To use source code files that contain ".ee." in their filename you must hold a valid n8n Enterprise License
specifically allowing you access to such source code files and as defined in "LICENSE_EE.md".
- Source code files that contain ".ee." in their filename or ".ee" in their dirname are NOT licensed under
the Sustainable Use License.
To use source code files that contain ".ee." in their filename or ".ee" in their dirname you must hold a
valid n8n Enterprise License specifically allowing you access to such source code files and as defined
in "LICENSE_EE.md".
- All third party components incorporated into the n8n Software are licensed under the original license
provided by the owner of the applicable component.
- Content outside of the above mentioned files or restrictions is available under the "Sustainable Use

View file

@ -18,11 +18,16 @@ n8n is a workflow automation platform that gives technical teams the flexibility
Try n8n instantly with [npx](https://docs.n8n.io/hosting/installation/npm/) (requires [Node.js](https://nodejs.org/en/)):
`npx n8n`
```
npx n8n
```
Or deploy with [Docker](https://docs.n8n.io/hosting/installation/docker/):
`docker run -it --rm --name n8n -p 5678:5678 docker.n8n.io/n8n-io/n8n`
```
docker volume create n8n_data
docker run -it --rm --name n8n -p 5678:5678 -v n8n_data:/home/node/.n8n docker.n8n.io/n8nio/n8n
```
Access the editor at http://localhost:5678

60
codecov.yml Normal file
View file

@ -0,0 +1,60 @@
codecov:
max_report_age: off
require_ci_to_pass: true
coverage:
status:
patch: false
project:
default:
threshold: 0.5%
github_checks:
annotations: false
flags:
tests:
paths:
- "**"
carryforward: true
component_management:
default_rules:
statuses:
- type: project
target: auto
branches:
- "!master"
individual_components:
- component_id: backend_packages
name: Backend
paths:
- packages/@n8n/api-types/**
- packages/@n8n/config/**
- packages/@n8n/client-oauth2/**
- packages/@n8n/di/**
- packages/@n8n/imap/**
- packages/@n8n/permissions/**
- packages/@n8n/task-runner/**
- packages/workflow/**
- packages/core/**
- packages/cli/**
- component_id: frontend_packages
name: Frontend
paths:
- packages/@n8n/chat/**
- packages/@n8n/codemirror-lang/**
- packages/design-system/**
- packages/editor-ui/**
- component_id: nodes_packages
name: Nodes
paths:
- packages/node-dev/**
- packages/nodes-base/**
- packages/@n8n/json-schema-to-zod/**
- packages/@n8n/nodes-langchain/**
ignore:
- (?s:.*/[^\/]*\.spec\.ts.*)\Z
- (?s:.*/[^\/]*\.test\.ts.*)\Z
- (?s:.*/[^\/]*e2e[^\/]*\.ts.*)\Z

View file

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

View file

@ -29,7 +29,11 @@ export const getAddProjectButton = () => {
return cy.get('@button');
};
export const getAddFirstProjectButton = () => cy.getByTestId('add-first-project-button');
export const getIconPickerButton = () => cy.getByTestId('icon-picker-button');
export const getIconPickerTab = (tab: string) => cy.getByTestId('icon-picker-tabs').contains(tab);
export const getIconPickerIcons = () => cy.getByTestId('icon-picker-icon');
export const getIconPickerEmojis = () => cy.getByTestId('icon-picker-emoji');
// export const getAddProjectButton = () =>
// cy.getByTestId('universal-add').should('contain', 'Add project').should('be.visible');
export const getProjectTabs = () => cy.getByTestId('project-tabs').find('a');

View file

@ -1,4 +1,5 @@
import { getManualChatModal } from './modals/chat-modal';
import { clickGetBackToCanvas, getParameterInputByName } from './ndv';
import { ROUTES } from '../constants';
/**
@ -6,6 +7,7 @@ import { ROUTES } from '../constants';
*/
export type EndpointType =
| 'main'
| 'ai_chain'
| 'ai_document'
| 'ai_embedding'
@ -23,8 +25,15 @@ export type EndpointType =
*/
export function getAddInputEndpointByType(nodeName: string, endpointType: EndpointType) {
return cy.get(
return cy.ifCanvasVersion(
() =>
cy.get(
`.add-input-endpoint[data-jtk-scope-${endpointType}][data-endpoint-name="${nodeName}"]`,
),
() =>
cy.get(
`[data-test-id="canvas-node-input-handle"][data-connection-type="${endpointType}"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"]`,
),
);
}
@ -45,7 +54,14 @@ export function getNodes() {
}
export function getNodeByName(name: string) {
return cy.getByTestId('canvas-node').filter(`[data-name="${name}"]`).eq(0);
return cy.ifCanvasVersion(
() => cy.getByTestId('canvas-node').filter(`[data-name="${name}"]`).eq(0),
() => cy.getByTestId('canvas-node').filter(`[data-node-name="${name}"]`).eq(0),
);
}
export function getWorkflowHistoryCloseButton() {
return cy.getByTestId('workflow-history-close-button');
}
export function disableNode(name: string) {
@ -55,10 +71,18 @@ export function disableNode(name: string) {
}
export function getConnectionBySourceAndTarget(source: string, target: string) {
return cy
return cy.ifCanvasVersion(
() =>
cy
.get('.jtk-connector')
.filter(`[data-source-node="${source}"][data-target-node="${target}"]`)
.eq(0);
.eq(0),
() =>
cy
.getByTestId('edge')
.filter(`[data-source-node-name="${source}"][data-target-node-name="${target}"]`)
.eq(0),
);
}
export function getNodeCreatorSearchBar() {
@ -127,7 +151,7 @@ export function navigateToNewWorkflowPage(preventNodeViewUnload = true) {
});
}
export function addSupplementalNodeToParent(
function connectNodeToParent(
nodeName: string,
endpointType: EndpointType,
parentNodeName: string,
@ -141,7 +165,28 @@ export function addSupplementalNodeToParent(
} else {
getNodeCreatorItems().contains(nodeName).click();
}
}
export function addSupplementalNodeToParent(
nodeName: string,
endpointType: EndpointType,
parentNodeName: string,
exactMatch = false,
) {
connectNodeToParent(nodeName, endpointType, parentNodeName, exactMatch);
cy.ifCanvasVersion(
() => {
getConnectionBySourceAndTarget(parentNodeName, nodeName).should('exist');
},
() => {
if (endpointType === 'main') {
getConnectionBySourceAndTarget(parentNodeName, nodeName).should('exist');
} else {
getConnectionBySourceAndTarget(nodeName, parentNodeName).should('exist');
}
},
);
}
export function addLanguageModelNodeToParent(
@ -160,6 +205,15 @@ export function addToolNodeToParent(nodeName: string, parentNodeName: string) {
addSupplementalNodeToParent(nodeName, 'ai_tool', parentNodeName);
}
export function addVectorStoreToolToParent(nodeName: string, parentNodeName: string) {
connectNodeToParent(nodeName, 'ai_tool', parentNodeName, false);
getParameterInputByName('mode')
.find('input')
.should('have.value', 'Retrieve Documents (As Tool for AI Agent)');
clickGetBackToCanvas();
getConnectionBySourceAndTarget(nodeName, parentNodeName).should('exist');
}
export function addOutputParserNodeToParent(nodeName: string, parentNodeName: string) {
addSupplementalNodeToParent(nodeName, 'ai_outputParser', parentNodeName);
}

View file

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

View file

@ -41,7 +41,9 @@ describe('Data mapping', () => {
ndv.actions.mapDataFromHeader(1, 'value');
ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.timestamp }}');
ndv.getters.inlineExpressionEditorInput().type('{esc}');
ndv.getters.parameterExpressionPreview('value').should('include.text', '2024');
ndv.getters
.parameterExpressionPreview('value')
.should('include.text', new Date().getFullYear());
ndv.actions.mapDataFromHeader(2, 'value');
ndv.getters
@ -113,6 +115,8 @@ describe('Data mapping', () => {
});
it('maps expressions from json view', () => {
// ADO-3063 - followup to make this viewport global
cy.viewport('macbook-16');
cy.fixture('Test_workflow_3.json').then((data) => {
cy.get('body').paste(JSON.stringify(data));
});
@ -121,17 +125,17 @@ describe('Data mapping', () => {
workflowPage.actions.openNode('Set');
ndv.actions.switchInputMode('JSON');
ndv.getters.inputDataContainer().should('exist');
ndv.getters
.inputDataContainer()
.should('exist')
.find('.json-data')
.should(
'have.text',
'[{"input": [{"count": 0,"with space": "!!","with.dot": "!!","with"quotes": "!!"}]},{"input": [{"count": 1}]}]',
)
.find('span')
.contains('"count"')
.realMouseDown();
);
ndv.getters.inputDataContainer().find('span').contains('"count"').realMouseDown();
ndv.actions.mapToParameter('value');
ndv.getters.inlineExpressionEditorInput().should('have.text', '{{ $json.input[0].count }}');

View file

@ -1,15 +1,17 @@
import planData from '../fixtures/Plan_data_opt_in_trial.json';
import {
BannerStack,
MainSidebar,
WorkflowPage,
visitPublicApiPage,
getPublicApiUpgradeCTA,
WorkflowsPage,
} from '../pages';
const NUMBER_OF_AI_CREDITS = 100;
const mainSidebar = new MainSidebar();
const bannerStack = new BannerStack();
const workflowPage = new WorkflowPage();
const workflowsPage = new WorkflowsPage();
describe('Cloud', () => {
before(() => {
@ -22,6 +24,10 @@ describe('Cloud', () => {
cy.overrideSettings({
deployment: { type: 'cloud' },
n8nMetadata: { userId: '1' },
aiCredits: {
enabled: true,
credits: NUMBER_OF_AI_CREDITS,
},
});
cy.intercept('GET', '/rest/admin/cloud-plan', planData).as('getPlanData');
cy.intercept('GET', '/rest/cloud/proxy/user/me', {}).as('getCloudUserInfo');
@ -40,11 +46,11 @@ describe('Cloud', () => {
it('should render trial banner for opt-in cloud user', () => {
visitWorkflowPage();
bannerStack.getters.banner().should('be.visible');
cy.getByTestId('banner-stack').should('be.visible');
mainSidebar.actions.signout();
bannerStack.getters.banner().should('not.be.visible');
cy.getByTestId('banner-stack').should('not.be.visible');
});
});
@ -64,4 +70,66 @@ describe('Cloud', () => {
getPublicApiUpgradeCTA().should('be.visible');
});
});
describe('Easy AI workflow experiment', () => {
it('should not show option to take you to the easy AI workflow if experiment is control', () => {
window.localStorage.setItem(
'N8N_EXPERIMENT_OVERRIDES',
JSON.stringify({ '026_easy_ai_workflow': 'control' }),
);
cy.visit(workflowsPage.url);
cy.getByTestId('easy-ai-workflow-card').should('not.exist');
});
it('should show option to take you to the easy AI workflow if experiment is variant', () => {
window.localStorage.setItem(
'N8N_EXPERIMENT_OVERRIDES',
JSON.stringify({ '026_easy_ai_workflow': 'variant' }),
);
cy.visit(workflowsPage.url);
cy.getByTestId('easy-ai-workflow-card').should('to.exist');
});
it('should show default instructions if free AI credits experiment is control', () => {
window.localStorage.setItem(
'N8N_EXPERIMENT_OVERRIDES',
JSON.stringify({ '027_free_openai_calls': 'control', '026_easy_ai_workflow': 'variant' }),
);
cy.visit(workflowsPage.url);
cy.getByTestId('easy-ai-workflow-card').click();
workflowPage.getters
.stickies()
.eq(0)
.should(($el) => {
expect($el).contains.text('Set up your OpenAI credentials in the OpenAI Model node');
});
});
it('should show updated instructions if free AI credits experiment is variant', () => {
window.localStorage.setItem(
'N8N_EXPERIMENT_OVERRIDES',
JSON.stringify({ '027_free_openai_calls': 'variant', '026_easy_ai_workflow': 'variant' }),
);
cy.visit(workflowsPage.url);
cy.getByTestId('easy-ai-workflow-card').click();
workflowPage.getters
.stickies()
.eq(0)
.should(($el) => {
expect($el).contains.text(
`Claim your free ${NUMBER_OF_AI_CREDITS} OpenAI calls in the OpenAI model node`,
);
});
});
});
});

View file

@ -1,18 +1,14 @@
import { getWorkflowHistoryCloseButton } from '../composables/workflow';
import {
CODE_NODE_NAME,
EDIT_FIELDS_SET_NODE_NAME,
IF_NODE_NAME,
SCHEDULE_TRIGGER_NODE_NAME,
} from '../constants';
import {
WorkflowExecutionsTab,
WorkflowPage as WorkflowPageClass,
WorkflowHistoryPage,
} from '../pages';
import { WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
const workflowPage = new WorkflowPageClass();
const executionsTab = new WorkflowExecutionsTab();
const workflowHistoryPage = new WorkflowHistoryPage();
const createNewWorkflowAndActivate = () => {
workflowPage.actions.visit();
@ -92,7 +88,7 @@ const switchBetweenEditorAndHistory = () => {
cy.wait(['@getVersion']);
cy.intercept('GET', '/rest/workflows/*').as('workflowGet');
workflowHistoryPage.getters.workflowHistoryCloseButton().click();
getWorkflowHistoryCloseButton().click();
cy.wait(['@workflowGet']);
cy.wait(1000);
@ -168,7 +164,7 @@ describe('Editor actions should work', () => {
cy.wait(['@getVersion']);
cy.intercept('GET', '/rest/workflows/*').as('workflowGet');
workflowHistoryPage.getters.workflowHistoryCloseButton().click();
getWorkflowHistoryCloseButton().click();
cy.wait(['@workflowGet']);
cy.wait(1000);

View file

@ -28,7 +28,7 @@ import {
clickGetBackToCanvas,
getRunDataInfoCallout,
getOutputPanelTable,
toggleParameterCheckboxInputByName,
checkParameterCheckboxInputByName,
} from '../composables/ndv';
import {
addLanguageModelNodeToParent,
@ -97,7 +97,7 @@ describe('Langchain Integration', () => {
it('should add nodes to all Agent node input types', () => {
addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME, true);
addNodeToCanvas(AGENT_NODE_NAME, true, true);
toggleParameterCheckboxInputByName('hasOutputParser');
checkParameterCheckboxInputByName('hasOutputParser');
clickGetBackToCanvas();
addLanguageModelNodeToParent(

View file

@ -3,6 +3,7 @@ import * as formStep from '../composables/setup-template-form-step';
import { getSetupWorkflowCredentialsButton } from '../composables/setup-workflow-credentials-button';
import TestTemplate1 from '../fixtures/Test_Template_1.json';
import TestTemplate2 from '../fixtures/Test_Template_2.json';
import { clearNotifications } from '../pages/notifications';
import {
clickUseWorkflowButtonByTitle,
visitTemplateCollectionPage,
@ -111,16 +112,19 @@ describe('Template credentials setup', () => {
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
clearNotifications();
templateCredentialsSetupPage.finishCredentialSetup();
workflowPage.getters.canvasNodes().should('have.length', 3);
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
// Focus the canvas so the copy to clipboard works
workflowPage.getters.canvasNodes().eq(0).realClick();
workflowPage.actions.hitSelectAll();
workflowPage.actions.hitCopy();
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
// Check workflow JSON by copying it to clipboard
cy.readClipboard().then((workflowJSON) => {
const workflow = JSON.parse(workflowJSON);
@ -154,6 +158,8 @@ describe('Template credentials setup', () => {
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Email (IMAP)');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Nextcloud');
clearNotifications();
templateCredentialsSetupPage.finishCredentialSetup();
workflowPage.getters.canvasNodes().should('have.length', 3);
@ -176,6 +182,8 @@ describe('Template credentials setup', () => {
templateCredentialsSetupPage.visitTemplateCredentialSetupPage(testTemplate.id);
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Shopify');
clearNotifications();
templateCredentialsSetupPage.finishCredentialSetup();
getSetupWorkflowCredentialsButton().should('be.visible');
@ -192,6 +200,8 @@ describe('Template credentials setup', () => {
templateCredentialsSetupPage.fillInDummyCredentialsForAppWithConfirm('X (Formerly Twitter)');
templateCredentialsSetupPage.fillInDummyCredentialsForApp('Telegram');
clearNotifications();
setupCredsModal.closeModalFromContinueButton();
setupCredsModal.getWorkflowCredentialsModal().should('not.exist');

View file

@ -1,22 +1,20 @@
import { SettingsPage } from '../pages/settings';
const settingsPage = new SettingsPage();
const url = '/settings';
describe('Admin user', { disableAutoLogin: true }, () => {
it('should see same Settings sub menu items as instance owner', () => {
cy.signinAsOwner();
cy.visit(settingsPage.url);
cy.visit(url);
let ownerMenuItems = 0;
settingsPage.getters.menuItems().then(($el) => {
cy.getByTestId('menu-item').then(($el) => {
ownerMenuItems = $el.length;
});
cy.signout();
cy.signinAsAdmin();
cy.visit(settingsPage.url);
cy.visit(url);
settingsPage.getters.menuItems().should('have.length', ownerMenuItems);
cy.getByTestId('menu-item').should('have.length', ownerMenuItems);
});
});

View file

@ -15,7 +15,7 @@ import {
NDV,
MainSidebar,
} from '../pages';
import { clearNotifications } from '../pages/notifications';
import { clearNotifications, successToast } from '../pages/notifications';
import { getVisibleDropdown, getVisibleModalOverlay, getVisibleSelect } from '../utils';
const workflowsPage = new WorkflowsPage();
@ -830,4 +830,23 @@ describe('Projects', { disableAutoLogin: true }, () => {
.should('not.have.length');
});
});
it('should set and update project icon', () => {
const DEFAULT_ICON = 'fa-layer-group';
const NEW_PROJECT_NAME = 'Test Project';
cy.signinAsAdmin();
cy.visit(workflowsPage.url);
projects.createProject(NEW_PROJECT_NAME);
// New project should have default icon
projects.getIconPickerButton().find('svg').should('have.class', DEFAULT_ICON);
// Choose another icon
projects.getIconPickerButton().click();
projects.getIconPickerTab('Emojis').click();
projects.getIconPickerEmojis().first().click();
// Project should be updated with new icon
successToast().contains('Project icon updated successfully');
projects.getIconPickerButton().should('contain', '😀');
projects.getMenuItems().contains(NEW_PROJECT_NAME).should('contain', '😀');
});
});

View file

@ -1,10 +1,12 @@
import { clickGetBackToCanvas } from '../composables/ndv';
import {
addNodeToCanvas,
addRetrieverNodeToParent,
addVectorStoreNodeToParent,
addVectorStoreToolToParent,
getNodeCreatorItems,
} from '../composables/workflow';
import { IF_NODE_NAME } from '../constants';
import { AGENT_NODE_NAME, IF_NODE_NAME, MANUAL_CHAT_TRIGGER_NODE_NAME } from '../constants';
import { NodeCreator } from '../pages/features/node-creator';
import { NDV } from '../pages/ndv';
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
@ -74,11 +76,21 @@ describe('Node Creator', () => {
nodeCreatorFeature.getters.canvasAddButton().click();
WorkflowPage.actions.addNodeToCanvas('Manual', false);
cy.ifCanvasVersion(
() => {
nodeCreatorFeature.getters.canvasAddButton().should('not.be.visible');
nodeCreatorFeature.getters.nodeCreator().should('not.exist');
// TODO: Replace once we have canvas feature utils
cy.get('div').contains('Add first step').should('be.hidden');
},
() => {
nodeCreatorFeature.getters.canvasAddButton().should('not.exist');
nodeCreatorFeature.getters.nodeCreator().should('not.exist');
// TODO: Replace once we have canvas feature utils
cy.get('div').contains('Add first step').should('not.exist');
},
);
nodeCreatorFeature.actions.openNodeCreator();
nodeCreatorFeature.getters.nodeCreator().contains('What happens next?').should('be.visible');
@ -344,7 +356,15 @@ describe('Node Creator', () => {
it('should correctly append a No Op node when Loop Over Items node is added (from connection)', () => {
WorkflowPage.actions.addNodeToCanvas('Manual');
cy.get('.plus-endpoint').should('be.visible').click();
cy.ifCanvasVersion(
() => {
cy.get('.plus-endpoint').click();
},
() => {
cy.getByTestId('canvas-handle-plus').click();
},
);
nodeCreatorFeature.getters.searchBar().find('input').type('Loop Over Items');
nodeCreatorFeature.getters.getCreatorItem('Loop Over Items').click();
@ -515,7 +535,7 @@ describe('Node Creator', () => {
const actions = [
'Get ranked documents from vector store',
'Add documents to vector store',
'Retrieve documents for AI processing',
'Retrieve documents for Chain/Tool as Vector Store',
];
nodeCreatorFeature.actions.openNodeCreator();
@ -529,14 +549,14 @@ describe('Node Creator', () => {
vectorStores.each((_i, vectorStore) => {
nodeCreatorFeature.getters.getCreatorItem(vectorStore).click();
actions.forEach((action) => {
nodeCreatorFeature.getters.getCreatorItem(action).should('be.visible');
nodeCreatorFeature.getters.getCreatorItem(action).should('be.visible').realHover();
});
cy.realPress('ArrowLeft');
});
});
});
it('should add node directly for sub-connection', () => {
it('should add node directly for sub-connection as vector store', () => {
addNodeToCanvas('Question and Answer Chain', true);
addRetrieverNodeToParent('Vector Store Retriever', 'Question and Answer Chain');
cy.realPress('Escape');
@ -544,4 +564,12 @@ describe('Node Creator', () => {
cy.realPress('Escape');
WorkflowPage.getters.canvasNodes().should('have.length', 4);
});
it('should add node directly for sub-connection as tool', () => {
addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true);
addNodeToCanvas(AGENT_NODE_NAME, true, true);
clickGetBackToCanvas();
addVectorStoreToolToParent('In-Memory Vector Store', AGENT_NODE_NAME);
});
});

View file

@ -94,7 +94,7 @@ describe('Workflow Selector Parameter', () => {
.findChildByTestId('rlc-item')
.eq(0)
.find('span')
.should('have.text', 'Create a new sub-workflow');
.should('contain.text', 'Create a'); // Due to some inconsistency we're sometimes in a project and sometimes not, this covers both cases
getVisiblePopper().findChildByTestId('rlc-item').eq(0).click();

View file

@ -40,7 +40,7 @@ describe('Subworkflow debugging', () => {
openNode('Execute Workflow with param');
getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution');
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
getOutputPanelRelatedExecutionLink().should('contain.text', 'View sub-execution');
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
// ensure workflow executed and waited on output
@ -64,7 +64,7 @@ describe('Subworkflow debugging', () => {
openNode('Execute Workflow with param2');
getOutputPanelItemsCount().should('not.exist');
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
getOutputPanelRelatedExecutionLink().should('contain.text', 'View sub-execution');
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
// ensure workflow executed but returned same data as input
@ -109,7 +109,7 @@ describe('Subworkflow debugging', () => {
openNode('Execute Workflow with param');
getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution');
getOutputPanelRelatedExecutionLink().should('contain.text', 'Inspect Sub-Execution');
getOutputPanelRelatedExecutionLink().should('contain.text', 'View sub-execution');
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
// ensure workflow executed and waited on output
@ -125,7 +125,7 @@ describe('Subworkflow debugging', () => {
getExecutionPreviewOutputPanelRelatedExecutionLink().should(
'include.text',
'Inspect Parent Execution',
'View parent execution',
);
getExecutionPreviewOutputPanelRelatedExecutionLink()

View file

@ -0,0 +1,242 @@
import {
addItemToFixedCollection,
assertNodeOutputHintExists,
clickExecuteNode,
clickGetBackToCanvas,
getExecuteNodeButton,
getOutputTableHeaders,
getParameterInputByName,
populateFixedCollection,
selectResourceLocatorItem,
typeIntoFixedCollectionItem,
clickWorkflowCardContent,
assertOutputTableContent,
populateMapperFields,
getNodeRunInfoStale,
assertNodeOutputErrorMessageExists,
checkParameterCheckboxInputByName,
uncheckParameterCheckboxInputByName,
} from '../composables/ndv';
import {
clickExecuteWorkflowButton,
clickZoomToFit,
navigateToNewWorkflowPage,
openNode,
pasteWorkflow,
saveWorkflowOnButtonClick,
} from '../composables/workflow';
import { visitWorkflowsPage } from '../composables/workflowsPage';
import SUB_WORKFLOW_INPUTS from '../fixtures/Test_Subworkflow-Inputs.json';
import { errorToast, successToast } from '../pages/notifications';
import { getVisiblePopper } from '../utils';
const DEFAULT_WORKFLOW_NAME = 'My workflow';
const DEFAULT_SUBWORKFLOW_NAME_1 = 'My Sub-Workflow 1';
const DEFAULT_SUBWORKFLOW_NAME_2 = 'My Sub-Workflow 2';
const EXAMPLE_FIELDS = [
['aNumber', 'Number'],
['aString', 'String'],
['aArray', 'Array'],
['aObject', 'Object'],
['aAny', 'Allow Any Type'],
// bool last because it's a switch instead of a normal inputField so we'll skip it for some cases
['aBool', 'Boolean'],
] as const;
type TypeField = 'Allow Any Type' | 'String' | 'Number' | 'Boolean' | 'Array' | 'Object';
describe('Sub-workflow creation and typed usage', () => {
beforeEach(() => {
navigateToNewWorkflowPage();
pasteWorkflow(SUB_WORKFLOW_INPUTS);
saveWorkflowOnButtonClick();
clickZoomToFit();
openNode('Execute Workflow');
// Prevent sub-workflow from opening in new window
cy.window().then((win) => {
cy.stub(win, 'open').callsFake((url) => {
cy.visit(url);
});
});
selectResourceLocatorItem('workflowId', 0, 'Create a');
// **************************
// NAVIGATE TO CHILD WORKFLOW
// **************************
openNode('Workflow Input Trigger');
});
it('works with type-checked values', () => {
populateFixedCollection(EXAMPLE_FIELDS, 'workflowInputs', 1);
validateAndReturnToParent(
DEFAULT_SUBWORKFLOW_NAME_1,
1,
EXAMPLE_FIELDS.map((f) => f[0]),
);
const values = [
'-1', // number fields don't support `=` switch to expression, so let's test the Fixed case with it
...EXAMPLE_FIELDS.slice(1).map((x) => `={{}{{} $json.a${x[0]}`), // the `}}` at the end are added automatically
];
// this matches with the pinned data provided in the fixture
populateMapperFields(values.map((x, i) => [EXAMPLE_FIELDS[i][0], x]));
clickExecuteNode();
const expected = [
['-1', 'A String', '0:11:true2:3', 'aKey:-1', '[empty object]', 'false'],
['-1', 'Another String', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'false'],
];
assertOutputTableContent(expected);
// Test the type-checking options
populateMapperFields([['aString', '{selectAll}{backspace}{{}{{} 5']]);
getNodeRunInfoStale().should('exist');
clickExecuteNode();
assertNodeOutputErrorMessageExists();
// attemptToConvertTypes enabled
checkParameterCheckboxInputByName('attemptToConvertTypes');
getNodeRunInfoStale().should('exist');
clickExecuteNode();
const expected2 = [
['-1', '5', '0:11:true2:3', 'aKey:-1', '[empty object]', 'false'],
['-1', '5', '[empty array]', 'aDifferentKey:-1', '[empty array]', 'false'],
];
assertOutputTableContent(expected2);
// disabled again
uncheckParameterCheckboxInputByName('attemptToConvertTypes');
getNodeRunInfoStale().should('exist');
clickExecuteNode();
assertNodeOutputErrorMessageExists();
});
it('works with Fields input source, and can then be changed to JSON input source', () => {
assertNodeOutputHintExists();
populateFixedCollection(EXAMPLE_FIELDS, 'workflowInputs', 1);
validateAndReturnToParent(
DEFAULT_SUBWORKFLOW_NAME_1,
1,
EXAMPLE_FIELDS.map((f) => f[0]),
);
cy.window().then((win) => {
cy.stub(win, 'open').callsFake((url) => {
cy.visit(url);
});
});
selectResourceLocatorItem('workflowId', 0, 'Create a');
openNode('Workflow Input Trigger');
getParameterInputByName('inputSource').click();
getVisiblePopper()
.getByTestId('parameter-input')
.eq(0)
.type('Using JSON Example{downArrow}{enter}');
const exampleJson =
'{{}' + EXAMPLE_FIELDS.map((x) => `"${x[0]}": ${makeExample(x[1])}`).join(',') + '}';
getParameterInputByName('jsonExample')
.find('.cm-line')
.eq(0)
.type(`{selectAll}{backspace}${exampleJson}{enter}`);
// first one doesn't work for some reason, might need to wait for something?
clickExecuteNode();
validateAndReturnToParent(
DEFAULT_SUBWORKFLOW_NAME_2,
2,
EXAMPLE_FIELDS.map((f) => f[0]),
);
assertOutputTableContent([
['[null]', '[null]', '[null]', '[null]', '[null]', 'false'],
['[null]', '[null]', '[null]', '[null]', '[null]', 'false'],
]);
clickExecuteNode();
});
it('should show node issue when no fields are defined in manual mode', () => {
getExecuteNodeButton().should('be.disabled');
clickGetBackToCanvas();
// Executing the workflow should show an error toast
clickExecuteWorkflowButton();
errorToast().should('contain', 'The workflow has issues');
openNode('Workflow Input Trigger');
// Add a field to the workflowInputs fixedCollection
addItemToFixedCollection('workflowInputs');
typeIntoFixedCollectionItem('workflowInputs', 0, 'test');
// Executing the workflow should not show error now
clickGetBackToCanvas();
clickExecuteWorkflowButton();
successToast().should('contain', 'Workflow executed successfully');
});
});
// This function starts off in the Child Workflow Input Trigger, assuming we just defined the input fields
// It then navigates back to the parent and validates the outputPanel matches our changes
function validateAndReturnToParent(targetChild: string, offset: number, fields: string[]) {
clickExecuteNode();
// + 1 to account for formatting-only column
getOutputTableHeaders().should('have.length', fields.length + 1);
for (const [i, name] of fields.entries()) {
getOutputTableHeaders().eq(i).should('have.text', name);
}
clickGetBackToCanvas();
saveWorkflowOnButtonClick();
visitWorkflowsPage();
clickWorkflowCardContent(DEFAULT_WORKFLOW_NAME);
openNode('Execute Workflow');
// Note that outside of e2e tests this will be pre-selected correctly.
// Due to our workaround to remain in the same tab we need to select the correct tab manually
selectResourceLocatorItem('workflowId', offset, targetChild);
clickExecuteNode();
getOutputTableHeaders().should('have.length', fields.length + 1);
for (const [i, name] of fields.entries()) {
getOutputTableHeaders().eq(i).should('have.text', name);
}
}
function makeExample(type: TypeField) {
switch (type) {
case 'String':
return '"example"';
case 'Number':
return '42';
case 'Boolean':
return 'true';
case 'Array':
return '["example", 123, null]';
case 'Object':
return '{{}"example": [123]}';
case 'Allow Any Type':
return 'null';
}
}

View file

@ -57,7 +57,7 @@ for (const item of $input.all()) {
return
`);
getParameter().get('.cm-lint-marker-error').should('have.length', 6);
getParameter().get('.cm-lintRange-error').should('have.length', 6);
getParameter().contains('itemMatching').realHover();
cy.get('.cm-tooltip-lint').should(
'have.text',
@ -81,7 +81,7 @@ $input.item()
return []
`);
getParameter().get('.cm-lint-marker-error').should('have.length', 5);
getParameter().get('.cm-lintRange-error').should('have.length', 5);
getParameter().contains('all').realHover();
cy.get('.cm-tooltip-lint').should(
'have.text',

View file

@ -171,9 +171,16 @@ describe('Workflow Actions', () => {
cy.get('#node-creator').should('not.exist');
WorkflowPage.actions.hitSelectAll();
cy.get('.jtk-drag-selected').should('have.length', 2);
WorkflowPage.actions.hitCopy();
successToast().should('exist');
// Both nodes should be copied
cy.window()
.its('navigator.clipboard')
.then((clip) => clip.readText())
.then((text) => {
const copiedWorkflow = JSON.parse(text);
expect(copiedWorkflow.nodes).to.have.length(2);
});
});
it('should paste nodes (both current and old node versions)', () => {
@ -345,7 +352,15 @@ describe('Workflow Actions', () => {
WorkflowPage.actions.hitDeleteAllNodes();
WorkflowPage.getters.canvasNodes().should('have.length', 0);
// Button should be disabled
cy.ifCanvasVersion(
() => {
WorkflowPage.getters.executeWorkflowButton().should('be.disabled');
},
() => {
// In new canvas, button does not exist when there are no nodes
WorkflowPage.getters.executeWorkflowButton().should('not.exist');
},
);
// Keyboard shortcut should not work
WorkflowPage.actions.hitExecuteWorkflow();
successToast().should('not.exist');

View file

@ -0,0 +1,69 @@
{
"meta": {
"instanceId": "4d0676b62208d810ef035130bbfc9fd3afdc78d963ea8ccb9514dc89066efc94"
},
"nodes": [
{
"parameters": {},
"id": "bb7f8bb3-840a-464c-a7de-d3a80538c2be",
"name": "When clicking Test workflow",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [0, 0]
},
{
"parameters": {
"workflowId": {},
"workflowInputs": {
"mappingMode": "defineBelow",
"value": {},
"matchingColumns": [],
"schema": [],
"attemptToConvertTypes": false,
"convertFieldsToString": true
},
"options": {}
},
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.2,
"position": [500, 240],
"id": "6b6e2e34-c6ab-4083-b8e3-6b0d56be5453",
"name": "Execute Workflow"
}
],
"connections": {
"When clicking Test workflow": {
"main": [
[
{
"node": "Execute Workflow",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {
"When clicking Test workflow": [
{
"aaString": "A String",
"aaNumber": 1,
"aaArray": [1, true, "3"],
"aaObject": {
"aKey": -1
},
"aaAny": {}
},
{
"aaString": "Another String",
"aaNumber": 2,
"aaArray": [],
"aaObject": {
"aDifferentKey": -1
},
"aaAny": []
}
]
}
}

View file

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

View file

@ -1,9 +0,0 @@
import { BasePage } from './base';
export class BannerStack extends BasePage {
getters = {
banner: () => cy.getByTestId('banner-stack'),
};
actions = {};
}

View file

@ -1,5 +1,13 @@
import type { IE2ETestPage } from '../types';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class BasePage implements IE2ETestPage {
getters = {};

View file

@ -1,5 +1,13 @@
import { BasePage } from './base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class CredentialsPage extends BasePage {
url = '/home/credentials';

View file

@ -8,6 +8,14 @@ const AI_ASSISTANT_FEATURE = {
disabledFor: 'control',
};
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class AIAssistant extends BasePage {
url = '/workflows/new';

View file

@ -1,5 +1,13 @@
import { BasePage } from '../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class NodeCreator extends BasePage {
url = '/workflow/new';

View file

@ -7,9 +7,7 @@ export * from './settings-users';
export * from './settings-log-streaming';
export * from './sidebar';
export * from './ndv';
export * from './bannerStack';
export * from './workflow-executions-tab';
export * from './signin';
export * from './workflow-history';
export * from './workerView';
export * from './settings-public-api';

View file

@ -3,6 +3,14 @@ import { SigninPage } from './signin';
import { WorkflowsPage } from './workflows';
import { N8N_AUTH_COOKIE } from '../constants';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class MfaLoginPage extends BasePage {
url = '/mfa';

View file

@ -1,5 +1,13 @@
import { BasePage } from './../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class ChangePasswordModal extends BasePage {
getters = {
modalContainer: () => cy.getByTestId('changePassword-modal').last(),

View file

@ -2,6 +2,14 @@ import { getCredentialSaveButton, saveCredential } from '../../composables/modal
import { getVisibleSelect } from '../../utils';
import { BasePage } from '../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class CredentialsModal extends BasePage {
getters = {
newCredentialModal: () => cy.getByTestId('selectCredential-modal', { timeout: 5000 }),
@ -61,6 +69,7 @@ export class CredentialsModal extends BasePage {
this.getters
.credentialInputs()
.find('input[type=text], input[type=password]')
.filter(':not([readonly])')
.each(($el) => {
cy.wrap($el).type('test');
});

View file

@ -1,5 +1,13 @@
import { BasePage } from '../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class MessageBox extends BasePage {
getters = {
modal: () => cy.get('.el-message-box', { withinSubject: null }),

View file

@ -1,5 +1,13 @@
import { BasePage } from './../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class MfaSetupModal extends BasePage {
getters = {
modalContainer: () => cy.getByTestId('changePassword-modal').last(),

View file

@ -1,5 +1,13 @@
import { BasePage } from '../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class WorkflowSharingModal extends BasePage {
getters = {
modal: () => cy.getByTestId('workflowShare-modal', { timeout: 5000 }),

View file

@ -1,6 +1,14 @@
import { BasePage } from './base';
import { getVisiblePopper, getVisibleSelect } from '../utils';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class NDV extends BasePage {
getters = {
container: () => cy.getByTestId('ndv'),
@ -320,6 +328,11 @@ export class NDV extends BasePage {
addItemToFixedCollection: (paramName: string) => {
this.getters.fixedCollectionParameter(paramName).getByTestId('fixed-collection-add').click();
},
typeIntoFixedCollectionItem: (fixedCollectionName: string, index: number, content: string) => {
this.getters.fixedCollectionParameter(fixedCollectionName).within(() => {
cy.getByTestId('parameter-input').eq(index).type(content);
});
},
dragMainPanelToLeft: () => {
cy.drag('[data-test-id=panel-drag-button]', [-1000, 0], { moveTwice: true });
},

View file

@ -13,5 +13,10 @@ export const infoToast = () => cy.get('.el-notification:has(.el-notification--in
* Actions
*/
export const clearNotifications = () => {
successToast().find('.el-notification__closeBtn').click({ multiple: true });
const buttons = successToast().find('.el-notification__closeBtn');
buttons.then(($buttons) => {
if ($buttons.length) {
buttons.click({ multiple: true });
}
});
};

View file

@ -1,6 +1,14 @@
import { BasePage } from './base';
import { getVisibleSelect } from '../utils';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class SettingsLogStreamingPage extends BasePage {
url = '/settings/log-streaming';

View file

@ -7,6 +7,14 @@ import { MfaSetupModal } from './modals/mfa-setup-modal';
const changePasswordModal = new ChangePasswordModal();
const mfaSetupModal = new MfaSetupModal();
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class PersonalSettingsPage extends BasePage {
url = '/settings/personal';

View file

@ -1,9 +0,0 @@
import { BasePage } from './base';
export class SettingsUsagePage extends BasePage {
url = '/settings/usage';
getters = {};
actions = {};
}

View file

@ -9,6 +9,14 @@ const workflowsPage = new WorkflowsPage();
const mainSidebar = new MainSidebar();
const settingsSidebar = new SettingsSidebar();
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class SettingsUsersPage extends BasePage {
url = '/settings/users';

View file

@ -1,11 +0,0 @@
import { BasePage } from './base';
export class SettingsPage extends BasePage {
url = '/settings';
getters = {
menuItems: () => cy.getByTestId('menu-item'),
};
actions = {};
}

View file

@ -1,6 +1,14 @@
import { BasePage } from '../base';
import { WorkflowsPage } from '../workflows';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class MainSidebar extends BasePage {
getters = {
menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id),

View file

@ -1,5 +1,13 @@
import { BasePage } from '../base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class SettingsSidebar extends BasePage {
getters = {
menuItem: (id: string) => cy.getByTestId('menu-item').get('#' + id),

View file

@ -2,6 +2,14 @@ import { BasePage } from './base';
import { WorkflowsPage } from './workflows';
import { N8N_AUTH_COOKIE } from '../constants';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class SigninPage extends BasePage {
url = '/signin';

View file

@ -1,5 +1,13 @@
import { BasePage } from './base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class TemplatesPage extends BasePage {
url = '/templates';

View file

@ -2,6 +2,14 @@ import { BasePage } from './base';
import Chainable = Cypress.Chainable;
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class VariablesPage extends BasePage {
url = '/variables';

View file

@ -1,5 +1,13 @@
import { BasePage } from './base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class WorkerViewPage extends BasePage {
url = '/settings/workers';

View file

@ -3,6 +3,14 @@ import { WorkflowPage } from './workflow';
const workflowPage = new WorkflowPage();
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class WorkflowExecutionsTab extends BasePage {
getters = {
executionsTabButton: () => cy.getByTestId('radio-button-executions'),

View file

@ -1,7 +0,0 @@
import { BasePage } from './base';
export class WorkflowHistoryPage extends BasePage {
getters = {
workflowHistoryCloseButton: () => cy.getByTestId('workflow-history-close-button'),
};
}

View file

@ -6,6 +6,15 @@ import { getVisibleSelect } from '../utils';
import { getUniqueWorkflowName, isCanvasV2 } from '../utils/workflowUtils';
const nodeCreator = new NodeCreator();
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class WorkflowPage extends BasePage {
url = '/workflow/new';
@ -49,13 +58,13 @@ export class WorkflowPage extends BasePage {
getEndpointSelector: (type: 'input' | 'output' | 'plus', nodeName: string, index = 0) => {
if (isCanvasV2()) {
if (type === 'input') {
return `[data-test-id="canvas-node-input-handle"][data-node-name="${nodeName}"][data-handle-index="${index}"]`;
return `[data-test-id="canvas-node-input-handle"][data-node-name="${nodeName}"][data-index="${index}"]`;
}
if (type === 'output') {
return `[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"][data-handle-index="${index}"]`;
return `[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"][data-index="${index}"]`;
}
if (type === 'plus') {
return `[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"][data-handle-index="${index}"] [data-test-id="canvas-handle-plus"] .clickable`;
return `[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"][data-index="${index}"] [data-test-id="canvas-handle-plus"]`;
}
}
return `[data-endpoint-name='${nodeName}'][data-endpoint-type='${type}'][data-input-index='${index}']`;
@ -72,7 +81,7 @@ export class WorkflowPage extends BasePage {
() =>
cy
.get(
`[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"] .clickable`,
`[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"]`,
)
.eq(index),
);
@ -94,7 +103,7 @@ export class WorkflowPage extends BasePage {
nodeConnections: () =>
cy.ifCanvasVersion(
() => cy.get('.jtk-connector'),
() => cy.getByTestId('edge-label-wrapper'),
() => cy.getByTestId('edge-label'),
),
zoomToFitButton: () => cy.getByTestId('zoom-to-fit'),
nodeEndpoints: () => cy.get('.jtk-endpoint-connected'),
@ -180,7 +189,7 @@ export class WorkflowPage extends BasePage {
),
() =>
cy.get(
`[data-test-id="edge-label-wrapper"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"]`,
`[data-test-id="edge-label"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"]`,
),
),
getConnectionActionsBetweenNodes: (sourceNodeName: string, targetNodeName: string) =>
@ -191,7 +200,7 @@ export class WorkflowPage extends BasePage {
),
() =>
cy.get(
`[data-test-id="edge-label-wrapper"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"] [data-test-id="canvas-edge-toolbar"]`,
`[data-test-id="edge-label"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"] [data-test-id="canvas-edge-toolbar"]`,
),
),
addStickyButton: () => cy.getByTestId('add-sticky-button'),

View file

@ -1,5 +1,13 @@
import { BasePage } from './base';
/**
* @deprecated Use functional composables from @composables instead.
* If a composable doesn't exist for your use case, please create a new one in:
* cypress/composables
*
* This class-based approach is being phased out in favor of more modular functional composables.
* Each getter and action in this class should be moved to individual composable functions.
*/
export class WorkflowsPage extends BasePage {
url = '/home/workflows';

View file

@ -33,7 +33,7 @@ COPY docker/images/n8n/docker-entrypoint.sh /
# Setup the Task Runner Launcher
ARG TARGETPLATFORM
ARG LAUNCHER_VERSION=1.0.0
ARG LAUNCHER_VERSION=1.1.0
COPY docker/images/n8n/n8n-task-runners.json /etc/n8n-task-runners.json
# Download, verify, then extract the launcher binary
RUN \

View file

@ -24,7 +24,7 @@ RUN set -eux; \
# Setup the Task Runner Launcher
ARG TARGETPLATFORM
ARG LAUNCHER_VERSION=1.0.0
ARG LAUNCHER_VERSION=1.1.0
COPY n8n-task-runners.json /etc/n8n-task-runners.json
# Download, verify, then extract the launcher binary
RUN \

View file

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

View file

@ -1,12 +1,12 @@
{
"name": "n8n-monorepo",
"version": "1.72.0",
"version": "1.74.0",
"private": true,
"engines": {
"node": ">=20.15",
"pnpm": ">=9.5"
"pnpm": ">=9.15"
},
"packageManager": "pnpm@9.6.0",
"packageManager": "pnpm@9.15.1",
"scripts": {
"prepare": "node scripts/prepare.mjs",
"preinstall": "node scripts/block-npm-install.js",
@ -18,6 +18,11 @@
"dev": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner",
"dev:be": "turbo run dev --parallel --env-mode=loose --filter=!n8n-design-system --filter=!@n8n/chat --filter=!@n8n/task-runner --filter=!n8n-editor-ui",
"dev:ai": "turbo run dev --parallel --env-mode=loose --filter=@n8n/nodes-langchain --filter=n8n --filter=n8n-core",
"dev:fe": "run-p start \"dev:fe:editor --filter=n8n-design-system\"",
"dev:fe:editor": "turbo run dev --parallel --env-mode=loose --filter=n8n-editor-ui",
"dev:e2e": "cd cypress && pnpm run test:e2e:dev",
"dev:e2e:v2": "cd cypress && pnpm run test:e2e:dev:v2",
"dev:e2e:server": "run-p start dev:fe:editor",
"clean": "turbo run clean --parallel",
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",
"format": "turbo run format && node scripts/format.mjs",
@ -55,6 +60,7 @@
"lefthook": "^1.7.15",
"nock": "^13.3.2",
"nodemon": "^3.0.1",
"npm-run-all2": "^7.0.2",
"p-limit": "^3.1.0",
"rimraf": "^5.0.1",
"run-script-os": "^1.0.7",
@ -84,7 +90,6 @@
"ws": ">=8.17.1"
},
"patchedDependencies": {
"typedi@0.10.0": "patches/typedi@0.10.0.patch",
"pkce-challenge@3.0.0": "patches/pkce-challenge@3.0.0.patch",
"pyodide@0.23.4": "patches/pyodide@0.23.4.patch",
"@types/express-serve-static-core@4.17.43": "patches/@types__express-serve-static-core@4.17.43.patch",

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/api-types",
"version": "0.10.0",
"version": "0.12.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",
@ -27,6 +27,6 @@
"dependencies": {
"xss": "catalog:",
"zod": "catalog:",
"zod-class": "0.0.15"
"zod-class": "0.0.16"
}
}

View file

@ -0,0 +1,36 @@
import { AiApplySuggestionRequestDto } from '../ai-apply-suggestion-request.dto';
describe('AiApplySuggestionRequestDto', () => {
it('should validate a valid suggestion application request', () => {
const validRequest = {
sessionId: 'session-123',
suggestionId: 'suggestion-456',
};
const result = AiApplySuggestionRequestDto.safeParse(validRequest);
expect(result.success).toBe(true);
});
it('should fail if sessionId is missing', () => {
const invalidRequest = {
suggestionId: 'suggestion-456',
};
const result = AiApplySuggestionRequestDto.safeParse(invalidRequest);
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toEqual(['sessionId']);
});
it('should fail if suggestionId is missing', () => {
const invalidRequest = {
sessionId: 'session-123',
};
const result = AiApplySuggestionRequestDto.safeParse(invalidRequest);
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toEqual(['suggestionId']);
});
});

View file

@ -0,0 +1,252 @@
import { AiAskRequestDto } from '../ai-ask-request.dto';
describe('AiAskRequestDto', () => {
const validRequest = {
question: 'How can I improve this workflow?',
context: {
schema: [
{
nodeName: 'TestNode',
schema: {
type: 'string',
key: 'testKey',
value: 'testValue',
path: '/test/path',
},
},
],
inputSchema: {
nodeName: 'InputNode',
schema: {
type: 'object',
key: 'inputKey',
value: [
{
type: 'string',
key: 'nestedKey',
value: 'nestedValue',
path: '/nested/path',
},
],
path: '/input/path',
},
},
pushRef: 'push-123',
ndvPushRef: 'ndv-push-456',
},
forNode: 'TestWorkflowNode',
};
it('should validate a valid AI ask request', () => {
const result = AiAskRequestDto.safeParse(validRequest);
expect(result.success).toBe(true);
});
it('should fail if question is missing', () => {
const invalidRequest = {
...validRequest,
question: undefined,
};
const result = AiAskRequestDto.safeParse(invalidRequest);
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toEqual(['question']);
});
it('should fail if context is invalid', () => {
const invalidRequest = {
...validRequest,
context: {
...validRequest.context,
schema: [
{
nodeName: 'TestNode',
schema: {
type: 'invalid-type', // Invalid type
value: 'testValue',
path: '/test/path',
},
},
],
},
};
const result = AiAskRequestDto.safeParse(invalidRequest);
expect(result.success).toBe(false);
});
it('should fail if forNode is missing', () => {
const invalidRequest = {
...validRequest,
forNode: undefined,
};
const result = AiAskRequestDto.safeParse(invalidRequest);
expect(result.success).toBe(false);
expect(result.error?.issues[0].path).toEqual(['forNode']);
});
it('should validate all possible schema types', () => {
const allTypesRequest = {
question: 'Test all possible types',
context: {
schema: [
{
nodeName: 'AllTypesNode',
schema: {
type: 'object',
key: 'typesRoot',
value: [
{ type: 'string', key: 'stringType', value: 'string', path: '/types/string' },
{ type: 'number', key: 'numberType', value: 'number', path: '/types/number' },
{ type: 'boolean', key: 'booleanType', value: 'boolean', path: '/types/boolean' },
{ type: 'bigint', key: 'bigintType', value: 'bigint', path: '/types/bigint' },
{ type: 'symbol', key: 'symbolType', value: 'symbol', path: '/types/symbol' },
{ type: 'array', key: 'arrayType', value: [], path: '/types/array' },
{ type: 'object', key: 'objectType', value: [], path: '/types/object' },
{
type: 'function',
key: 'functionType',
value: 'function',
path: '/types/function',
},
{ type: 'null', key: 'nullType', value: 'null', path: '/types/null' },
{
type: 'undefined',
key: 'undefinedType',
value: 'undefined',
path: '/types/undefined',
},
],
path: '/types/root',
},
},
],
inputSchema: {
nodeName: 'InputNode',
schema: {
type: 'object',
key: 'simpleInput',
value: [
{
type: 'string',
key: 'simpleKey',
value: 'simpleValue',
path: '/simple/path',
},
],
path: '/simple/input/path',
},
},
pushRef: 'push-types-123',
ndvPushRef: 'ndv-push-types-456',
},
forNode: 'TypeCheckNode',
};
const result = AiAskRequestDto.safeParse(allTypesRequest);
expect(result.success).toBe(true);
});
it('should fail with invalid type', () => {
const invalidTypeRequest = {
question: 'Test invalid type',
context: {
schema: [
{
nodeName: 'InvalidTypeNode',
schema: {
type: 'invalid-type', // This should fail
key: 'invalidKey',
value: 'invalidValue',
path: '/invalid/path',
},
},
],
inputSchema: {
nodeName: 'InputNode',
schema: {
type: 'object',
key: 'simpleInput',
value: [
{
type: 'string',
key: 'simpleKey',
value: 'simpleValue',
path: '/simple/path',
},
],
path: '/simple/input/path',
},
},
pushRef: 'push-invalid-123',
ndvPushRef: 'ndv-push-invalid-456',
},
forNode: 'InvalidTypeNode',
};
const result = AiAskRequestDto.safeParse(invalidTypeRequest);
expect(result.success).toBe(false);
});
it('should validate multiple schema entries', () => {
const multiSchemaRequest = {
question: 'Multiple schema test',
context: {
schema: [
{
nodeName: 'FirstNode',
schema: {
type: 'string',
key: 'firstKey',
value: 'firstValue',
path: '/first/path',
},
},
{
nodeName: 'SecondNode',
schema: {
type: 'object',
key: 'secondKey',
value: [
{
type: 'number',
key: 'nestedKey',
value: 'nestedValue',
path: '/second/nested/path',
},
],
path: '/second/path',
},
},
],
inputSchema: {
nodeName: 'InputNode',
schema: {
type: 'object',
key: 'simpleInput',
value: [
{
type: 'string',
key: 'simpleKey',
value: 'simpleValue',
path: '/simple/path',
},
],
path: '/simple/input/path',
},
},
pushRef: 'push-multi-123',
ndvPushRef: 'ndv-push-multi-456',
},
forNode: 'MultiSchemaNode',
};
const result = AiAskRequestDto.safeParse(multiSchemaRequest);
expect(result.success).toBe(true);
});
});

View file

@ -0,0 +1,34 @@
import { AiChatRequestDto } from '../ai-chat-request.dto';
describe('AiChatRequestDto', () => {
it('should validate a request with a payload and session ID', () => {
const validRequest = {
payload: { someKey: 'someValue' },
sessionId: 'session-123',
};
const result = AiChatRequestDto.safeParse(validRequest);
expect(result.success).toBe(true);
});
it('should validate a request with only a payload', () => {
const validRequest = {
payload: { complexObject: { nested: 'value' } },
};
const result = AiChatRequestDto.safeParse(validRequest);
expect(result.success).toBe(true);
});
it('should fail if payload is missing', () => {
const invalidRequest = {
sessionId: 'session-123',
};
const result = AiChatRequestDto.safeParse(invalidRequest);
expect(result.success).toBe(false);
});
});

View file

@ -0,0 +1,32 @@
import { nanoId } from 'minifaker';
import { AiFreeCreditsRequestDto } from '../ai-free-credits-request.dto';
import 'minifaker/locales/en';
describe('AiChatRequestDto', () => {
it('should succeed if projectId is a valid nanoid', () => {
const validRequest = {
projectId: nanoId.nanoid(),
};
const result = AiFreeCreditsRequestDto.safeParse(validRequest);
expect(result.success).toBe(true);
});
it('should succeed if no projectId is sent', () => {
const result = AiFreeCreditsRequestDto.safeParse({});
expect(result.success).toBe(true);
});
it('should fail is projectId invalid value', () => {
const validRequest = {
projectId: '',
};
const result = AiFreeCreditsRequestDto.safeParse(validRequest);
expect(result.success).toBe(false);
});
});

View file

@ -0,0 +1,7 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class AiApplySuggestionRequestDto extends Z.class({
sessionId: z.string(),
suggestionId: z.string(),
}) {}

View file

@ -0,0 +1,53 @@
import type { AiAssistantSDK, SchemaType } from '@n8n_io/ai-assistant-sdk';
import { z } from 'zod';
import { Z } from 'zod-class';
// Note: This is copied from the sdk, since this type is not exported
type Schema = {
type: SchemaType;
key?: string;
value: string | Schema[];
path: string;
};
// Create a lazy validator to handle the recursive type
const schemaValidator: z.ZodType<Schema> = z.lazy(() =>
z.object({
type: z.enum([
'string',
'number',
'boolean',
'bigint',
'symbol',
'array',
'object',
'function',
'null',
'undefined',
]),
key: z.string().optional(),
value: z.union([z.string(), z.lazy(() => schemaValidator.array())]),
path: z.string(),
}),
);
export class AiAskRequestDto
extends Z.class({
question: z.string(),
context: z.object({
schema: z.array(
z.object({
nodeName: z.string(),
schema: schemaValidator,
}),
),
inputSchema: z.object({
nodeName: z.string(),
schema: schemaValidator,
}),
pushRef: z.string(),
ndvPushRef: z.string(),
}),
forNode: z.string(),
})
implements AiAssistantSDK.AskAiRequestPayload {}

View file

@ -0,0 +1,10 @@
import type { AiAssistantSDK } from '@n8n_io/ai-assistant-sdk';
import { z } from 'zod';
import { Z } from 'zod-class';
export class AiChatRequestDto
extends Z.class({
payload: z.object({}).passthrough(), // Allow any object shape
sessionId: z.string().optional(),
})
implements AiAssistantSDK.ChatRequestPayload {}

View file

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

View file

@ -0,0 +1,93 @@
import { LoginRequestDto } from '../login-request.dto';
describe('LoginRequestDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'complete valid login request',
request: {
email: 'test@example.com',
password: 'securePassword123',
mfaCode: '123456',
},
},
{
name: 'login request without optional MFA',
request: {
email: 'test@example.com',
password: 'securePassword123',
},
},
{
name: 'login request with both mfaCode and mfaRecoveryCode',
request: {
email: 'test@example.com',
password: 'securePassword123',
mfaCode: '123456',
mfaRecoveryCode: 'recovery-code-123',
},
},
{
name: 'login request with only mfaRecoveryCode',
request: {
email: 'test@example.com',
password: 'securePassword123',
mfaRecoveryCode: 'recovery-code-123',
},
},
])('should validate $name', ({ request }) => {
const result = LoginRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid email',
request: {
email: 'invalid-email',
password: 'securePassword123',
},
expectedErrorPath: ['email'],
},
{
name: 'empty password',
request: {
email: 'test@example.com',
password: '',
},
expectedErrorPath: ['password'],
},
{
name: 'missing email',
request: {
password: 'securePassword123',
},
expectedErrorPath: ['email'],
},
{
name: 'missing password',
request: {
email: 'test@example.com',
},
expectedErrorPath: ['password'],
},
{
name: 'whitespace in email and password',
request: {
email: ' test@example.com ',
password: ' securePassword123 ',
},
expectedErrorPath: ['email'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = LoginRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,87 @@
import { ResolveSignupTokenQueryDto } from '../resolve-signup-token-query.dto';
describe('ResolveSignupTokenQueryDto', () => {
const validUuid = '123e4567-e89b-12d3-a456-426614174000';
describe('Valid requests', () => {
test.each([
{
name: 'standard UUID',
request: {
inviterId: validUuid,
inviteeId: validUuid,
},
},
])('should validate $name', ({ request }) => {
const result = ResolveSignupTokenQueryDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid inviterId UUID',
request: {
inviterId: 'not-a-valid-uuid',
inviteeId: validUuid,
},
expectedErrorPath: ['inviterId'],
},
{
name: 'invalid inviteeId UUID',
request: {
inviterId: validUuid,
inviteeId: 'not-a-valid-uuid',
},
expectedErrorPath: ['inviteeId'],
},
{
name: 'missing inviterId',
request: {
inviteeId: validUuid,
},
expectedErrorPath: ['inviterId'],
},
{
name: 'missing inviteeId',
request: {
inviterId: validUuid,
},
expectedErrorPath: ['inviteeId'],
},
{
name: 'UUID with invalid characters',
request: {
inviterId: '123e4567-e89b-12d3-a456-42661417400G',
inviteeId: validUuid,
},
expectedErrorPath: ['inviterId'],
},
{
name: 'UUID too long',
request: {
inviterId: '123e4567-e89b-12d3-a456-426614174001234',
inviteeId: validUuid,
},
expectedErrorPath: ['inviterId'],
},
{
name: 'UUID too short',
request: {
inviterId: '123e4567-e89b-12d3-a456',
inviteeId: validUuid,
},
expectedErrorPath: ['inviterId'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = ResolveSignupTokenQueryDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,9 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class LoginRequestDto extends Z.class({
email: z.string().email(),
password: z.string().min(1),
mfaCode: z.string().optional(),
mfaRecoveryCode: z.string().optional(),
}) {}

View file

@ -0,0 +1,7 @@
import { z } from 'zod';
import { Z } from 'zod-class';
export class ResolveSignupTokenQueryDto extends Z.class({
inviterId: z.string().uuid(),
inviteeId: z.string().uuid(),
}) {}

View file

@ -0,0 +1,55 @@
import { CredentialsGetManyRequestQuery } from '../credentials-get-many-request.dto';
describe('CredentialsGetManyRequestQuery', () => {
describe('should pass validation', () => {
it('with empty object', () => {
const data = {};
const result = CredentialsGetManyRequestQuery.safeParse(data);
expect(result.success).toBe(true);
});
test.each([
{ field: 'includeScopes', value: 'true' },
{ field: 'includeScopes', value: 'false' },
{ field: 'includeData', value: 'true' },
{ field: 'includeData', value: 'false' },
])('with $field set to $value', ({ field, value }) => {
const data = { [field]: value };
const result = CredentialsGetManyRequestQuery.safeParse(data);
expect(result.success).toBe(true);
});
it('with both parameters set', () => {
const data = {
includeScopes: 'true',
includeData: 'true',
};
const result = CredentialsGetManyRequestQuery.safeParse(data);
expect(result.success).toBe(true);
});
});
describe('should fail validation', () => {
test.each([
{ field: 'includeScopes', value: true },
{ field: 'includeScopes', value: false },
{ field: 'includeScopes', value: 'invalid' },
{ field: 'includeData', value: true },
{ field: 'includeData', value: false },
{ field: 'includeData', value: 'invalid' },
])('with invalid value $value for $field', ({ field, value }) => {
const data = { [field]: value };
const result = CredentialsGetManyRequestQuery.safeParse(data);
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe(field);
});
});
});

View file

@ -0,0 +1,52 @@
import { CredentialsGetOneRequestQuery } from '../credentials-get-one-request.dto';
describe('CredentialsGetManyRequestQuery', () => {
describe('should pass validation', () => {
it('with empty object', () => {
const data = {};
const result = CredentialsGetOneRequestQuery.safeParse(data);
expect(result.success).toBe(true);
// defaults to false
expect(result.data?.includeData).toBe(false);
});
test.each([
{ field: 'includeData', value: 'true' },
{ field: 'includeData', value: 'false' },
])('with $field set to $value', ({ field, value }) => {
const data = { [field]: value };
const result = CredentialsGetOneRequestQuery.safeParse(data);
expect(result.success).toBe(true);
});
it('with both parameters set', () => {
const data = {
includeScopes: 'true',
includeData: 'true',
};
const result = CredentialsGetOneRequestQuery.safeParse(data);
expect(result.success).toBe(true);
});
});
describe('should fail validation', () => {
test.each([
{ field: 'includeData', value: true },
{ field: 'includeData', value: false },
{ field: 'includeData', value: 'invalid' },
])('with invalid value $value for $field', ({ field, value }) => {
const data = { [field]: value };
const result = CredentialsGetOneRequestQuery.safeParse(data);
expect(result.success).toBe(false);
expect(result.error?.issues[0].path[0]).toBe(field);
});
});
});

View file

@ -0,0 +1,22 @@
import { Z } from 'zod-class';
import { booleanFromString } from '../../schemas/booleanFromString';
export class CredentialsGetManyRequestQuery extends Z.class({
/**
* Adds the `scopes` field to each credential which includes all scopes the
* requesting user has in relation to the credential, e.g.
* ['credential:read', 'credential:update']
*/
includeScopes: booleanFromString.optional(),
/**
* Adds the decrypted `data` field to each credential.
*
* It only does this for credentials for which the user has the
* `credential:update` scope.
*
* This switches `includeScopes` to true to be able to check for the scopes
*/
includeData: booleanFromString.optional(),
}) {}

View file

@ -0,0 +1,13 @@
import { Z } from 'zod-class';
import { booleanFromString } from '../../schemas/booleanFromString';
export class CredentialsGetOneRequestQuery extends Z.class({
/**
* Adds the decrypted `data` field to each credential.
*
* It only does this for credentials for which the user has the
* `credential:update` scope.
*/
includeData: booleanFromString.optional().default('false'),
}) {}

View file

@ -0,0 +1,81 @@
import { ActionResultRequestDto } from '../action-result-request.dto';
describe('ActionResultRequestDto', () => {
const baseValidRequest = {
path: '/test/path',
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
handler: 'testHandler',
currentNodeParameters: {},
};
describe('Valid requests', () => {
test.each([
{
name: 'minimal valid request',
request: baseValidRequest,
},
{
name: 'request with payload',
request: {
...baseValidRequest,
payload: { key: 'value' },
},
},
{
name: 'request with credentials',
request: {
...baseValidRequest,
credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } },
},
},
{
name: 'request with current node parameters',
request: {
...baseValidRequest,
currentNodeParameters: { param1: 'value1' },
},
},
])('should validate $name', ({ request }) => {
const result = ActionResultRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing path',
request: {
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
handler: 'testHandler',
},
expectedErrorPath: ['path'],
},
{
name: 'missing handler',
request: {
path: '/test/path',
currentNodeParameters: {},
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
},
expectedErrorPath: ['handler'],
},
{
name: 'invalid node version',
request: {
...baseValidRequest,
nodeTypeAndVersion: { name: 'TestNode', version: 0 },
},
expectedErrorPath: ['nodeTypeAndVersion', 'version'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = ActionResultRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,90 @@
import { OptionsRequestDto } from '../options-request.dto';
describe('OptionsRequestDto', () => {
const baseValidRequest = {
path: '/test/path',
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
currentNodeParameters: {},
};
describe('Valid requests', () => {
test.each([
{
name: 'minimal valid request',
request: baseValidRequest,
},
{
name: 'request with method name',
request: {
...baseValidRequest,
methodName: 'testMethod',
},
},
{
name: 'request with load options',
request: {
...baseValidRequest,
loadOptions: {
routing: {
operations: { someOperation: 'test' },
output: { someOutput: 'test' },
request: { someRequest: 'test' },
},
},
},
},
{
name: 'request with credentials',
request: {
...baseValidRequest,
credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } },
},
},
{
name: 'request with current node parameters',
request: {
...baseValidRequest,
currentNodeParameters: { param1: 'value1' },
},
},
])('should validate $name', ({ request }) => {
const result = OptionsRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing path',
request: {
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
},
expectedErrorPath: ['path'],
},
{
name: 'missing node type and version',
request: {
path: '/test/path',
},
expectedErrorPath: ['nodeTypeAndVersion'],
},
{
name: 'invalid node version',
request: {
...baseValidRequest,
nodeTypeAndVersion: { name: 'TestNode', version: 0 },
},
expectedErrorPath: ['nodeTypeAndVersion', 'version'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = OptionsRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,95 @@
import { ResourceLocatorRequestDto } from '../resource-locator-request.dto';
describe('ResourceLocatorRequestDto', () => {
const baseValidRequest = {
path: '/test/path',
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
methodName: 'testMethod',
currentNodeParameters: {},
};
describe('Valid requests', () => {
test.each([
{
name: 'minimal valid request',
request: baseValidRequest,
},
{
name: 'request with filter',
request: {
...baseValidRequest,
filter: 'testFilter',
},
},
{
name: 'request with pagination token',
request: {
...baseValidRequest,
paginationToken: 'token123',
},
},
{
name: 'request with credentials',
request: {
...baseValidRequest,
credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } },
},
},
{
name: 'request with current node parameters',
request: {
...baseValidRequest,
currentNodeParameters: { param1: 'value1' },
},
},
{
name: 'request with a semver node version',
request: {
...baseValidRequest,
nodeTypeAndVersion: { name: 'TestNode', version: 1.1 },
},
},
])('should validate $name', ({ request }) => {
const result = ResourceLocatorRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing path',
request: {
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
methodName: 'testMethod',
},
expectedErrorPath: ['path'],
},
{
name: 'missing method name',
request: {
path: '/test/path',
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
currentNodeParameters: {},
},
expectedErrorPath: ['methodName'],
},
{
name: 'invalid node version',
request: {
...baseValidRequest,
nodeTypeAndVersion: { name: 'TestNode', version: 0 },
},
expectedErrorPath: ['nodeTypeAndVersion', 'version'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = ResourceLocatorRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,74 @@
import { ResourceMapperFieldsRequestDto } from '../resource-mapper-fields-request.dto';
describe('ResourceMapperFieldsRequestDto', () => {
const baseValidRequest = {
path: '/test/path',
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
methodName: 'testMethod',
currentNodeParameters: {},
};
describe('Valid requests', () => {
test.each([
{
name: 'minimal valid request',
request: baseValidRequest,
},
{
name: 'request with credentials',
request: {
...baseValidRequest,
credentials: { testCredential: { id: 'cred1', name: 'Test Cred' } },
},
},
{
name: 'request with current node parameters',
request: {
...baseValidRequest,
currentNodeParameters: { param1: 'value1' },
},
},
])('should validate $name', ({ request }) => {
const result = ResourceMapperFieldsRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing path',
request: {
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
methodName: 'testMethod',
},
expectedErrorPath: ['path'],
},
{
name: 'missing method name',
request: {
path: '/test/path',
nodeTypeAndVersion: { name: 'TestNode', version: 1 },
currentNodeParameters: {},
},
expectedErrorPath: ['methodName'],
},
{
name: 'invalid node version',
request: {
...baseValidRequest,
nodeTypeAndVersion: { name: 'TestNode', version: 0 },
},
expectedErrorPath: ['nodeTypeAndVersion', 'version'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = ResourceMapperFieldsRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,11 @@
import type { IDataObject } from 'n8n-workflow';
import { z } from 'zod';
import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto';
export class ActionResultRequestDto extends BaseDynamicParametersRequestDto.extend({
handler: z.string(),
payload: z
.union([z.object({}).catchall(z.any()) satisfies z.ZodType<IDataObject>, z.string()])
.optional(),
}) {}

View file

@ -0,0 +1,18 @@
import type { INodeCredentials, INodeParameters, INodeTypeNameVersion } from 'n8n-workflow';
import { z } from 'zod';
import { Z } from 'zod-class';
import { nodeVersionSchema } from '../../schemas/nodeVersion.schema';
export class BaseDynamicParametersRequestDto extends Z.class({
path: z.string(),
nodeTypeAndVersion: z.object({
name: z.string(),
version: nodeVersionSchema,
}) satisfies z.ZodType<INodeTypeNameVersion>,
currentNodeParameters: z.record(z.string(), z.any()) satisfies z.ZodType<INodeParameters>,
methodName: z.string().optional(),
credentials: z.record(z.string(), z.any()).optional() satisfies z.ZodType<
INodeCredentials | undefined
>,
}) {}

View file

@ -0,0 +1,18 @@
import type { ILoadOptions } from 'n8n-workflow';
import { z } from 'zod';
import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto';
export class OptionsRequestDto extends BaseDynamicParametersRequestDto.extend({
loadOptions: z
.object({
routing: z
.object({
operations: z.any().optional(),
output: z.any().optional(),
request: z.any().optional(),
})
.optional(),
})
.optional() as z.ZodType<ILoadOptions | undefined>,
}) {}

View file

@ -0,0 +1,9 @@
import { z } from 'zod';
import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto';
export class ResourceLocatorRequestDto extends BaseDynamicParametersRequestDto.extend({
methodName: z.string(),
filter: z.string().optional(),
paginationToken: z.string().optional(),
}) {}

View file

@ -0,0 +1,7 @@
import { z } from 'zod';
import { BaseDynamicParametersRequestDto } from './base-dynamic-parameters-request.dto';
export class ResourceMapperFieldsRequestDto extends BaseDynamicParametersRequestDto.extend({
methodName: z.string(),
}) {}

View file

@ -1,6 +1,49 @@
export { AiAskRequestDto } from './ai/ai-ask-request.dto';
export { AiChatRequestDto } from './ai/ai-chat-request.dto';
export { AiApplySuggestionRequestDto } from './ai/ai-apply-suggestion-request.dto';
export { AiFreeCreditsRequestDto } from './ai/ai-free-credits-request.dto';
export { LoginRequestDto } from './auth/login-request.dto';
export { ResolveSignupTokenQueryDto } from './auth/resolve-signup-token-query.dto';
export { OptionsRequestDto } from './dynamic-node-parameters/options-request.dto';
export { ResourceLocatorRequestDto } from './dynamic-node-parameters/resource-locator-request.dto';
export { ResourceMapperFieldsRequestDto } from './dynamic-node-parameters/resource-mapper-fields-request.dto';
export { ActionResultRequestDto } from './dynamic-node-parameters/action-result-request.dto';
export { InviteUsersRequestDto } from './invitation/invite-users-request.dto';
export { AcceptInvitationRequestDto } from './invitation/accept-invitation-request.dto';
export { OwnerSetupRequestDto } from './owner/owner-setup-request.dto';
export { DismissBannerRequestDto } from './owner/dismiss-banner-request.dto';
export { ForgotPasswordRequestDto } from './password-reset/forgot-password-request.dto';
export { ResolvePasswordTokenQueryDto } from './password-reset/resolve-password-token-query.dto';
export { ChangePasswordRequestDto } from './password-reset/change-password-request.dto';
export { CreateProjectDto } from './project/create-project.dto';
export { UpdateProjectDto } from './project/update-project.dto';
export { DeleteProjectDto } from './project/delete-project.dto';
export { SamlAcsDto } from './saml/saml-acs.dto';
export { SamlPreferences } from './saml/saml-preferences.dto';
export { SamlToggleDto } from './saml/saml-toggle.dto';
export { PasswordUpdateRequestDto } from './user/password-update-request.dto';
export { RoleChangeRequestDto } from './user/role-change-request.dto';
export { SettingsUpdateRequestDto } from './user/settings-update-request.dto';
export { UserUpdateRequestDto } from './user/user-update-request.dto';
export { CommunityRegisteredRequestDto } from './license/community-registered-request.dto';
export { PullWorkFolderRequestDto } from './source-control/pull-work-folder-request.dto';
export { PushWorkFolderRequestDto } from './source-control/push-work-folder-request.dto';
export { VariableListRequestDto } from './variables/variables-list-request.dto';
export { CredentialsGetOneRequestQuery } from './credentials/credentials-get-one-request.dto';
export { CredentialsGetManyRequestQuery } from './credentials/credentials-get-many-request.dto';
export { ImportWorkflowFromUrlDto } from './workflows/import-workflow-from-url.dto';
export { CreateOrUpdateTagRequestDto } from './tag/create-or-update-tag-request.dto';
export { RetrieveTagQueryDto } from './tag/retrieve-tag-query.dto';

View file

@ -0,0 +1,94 @@
import { AcceptInvitationRequestDto } from '../accept-invitation-request.dto';
describe('AcceptInvitationRequestDto', () => {
const validUuid = '123e4567-e89b-12d3-a456-426614174000';
describe('Valid requests', () => {
test.each([
{
name: 'complete valid invitation acceptance',
request: {
inviterId: validUuid,
firstName: 'John',
lastName: 'Doe',
password: 'SecurePassword123',
},
},
])('should validate $name', ({ request }) => {
const result = AcceptInvitationRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing inviterId',
request: {
firstName: 'John',
lastName: 'Doe',
password: 'SecurePassword123',
},
expectedErrorPath: ['inviterId'],
},
{
name: 'invalid inviterId',
request: {
inviterId: 'not-a-valid-uuid',
firstName: 'John',
lastName: 'Doe',
password: 'SecurePassword123',
},
expectedErrorPath: ['inviterId'],
},
{
name: 'missing first name',
request: {
inviterId: validUuid,
firstName: '',
lastName: 'Doe',
password: 'SecurePassword123',
},
expectedErrorPath: ['firstName'],
},
{
name: 'missing last name',
request: {
inviterId: validUuid,
firstName: 'John',
lastName: '',
password: 'SecurePassword123',
},
expectedErrorPath: ['lastName'],
},
{
name: 'password too short',
request: {
inviterId: validUuid,
firstName: 'John',
lastName: 'Doe',
password: 'short',
},
expectedErrorPath: ['password'],
},
{
name: 'password without number',
request: {
inviterId: validUuid,
firstName: 'John',
lastName: 'Doe',
password: 'NoNumberPassword',
},
expectedErrorPath: ['password'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = AcceptInvitationRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,60 @@
import { InviteUsersRequestDto } from '../invite-users-request.dto';
describe('InviteUsersRequestDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'empty array',
request: [],
},
{
name: 'single user invitation with default role',
request: [{ email: 'user@example.com' }],
},
{
name: 'multiple user invitations with different roles',
request: [
{ email: 'user1@example.com', role: 'global:member' },
{ email: 'user2@example.com', role: 'global:admin' },
],
},
])('should validate $name', ({ request }) => {
const result = InviteUsersRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
it('should default role to global:member', () => {
const result = InviteUsersRequestDto.safeParse([{ email: 'user@example.com' }]);
expect(result.success).toBe(true);
expect(result.data?.[0].role).toBe('global:member');
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid email',
request: [{ email: 'invalid-email' }],
expectedErrorPath: [0, 'email'],
},
{
name: 'invalid role',
request: [
{
email: 'user@example.com',
role: 'invalid-role',
},
],
expectedErrorPath: [0, 'role'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = InviteUsersRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,11 @@
import { z } from 'zod';
import { Z } from 'zod-class';
import { passwordSchema } from '../../schemas/password.schema';
export class AcceptInvitationRequestDto extends Z.class({
inviterId: z.string().uuid(),
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
password: passwordSchema,
}) {}

View file

@ -0,0 +1,16 @@
import { z } from 'zod';
const roleSchema = z.enum(['global:member', 'global:admin']);
const invitedUserSchema = z.object({
email: z.string().email(),
role: roleSchema.default('global:member'),
});
const invitationsSchema = z.array(invitedUserSchema);
export class InviteUsersRequestDto extends Array<z.infer<typeof invitedUserSchema>> {
static safeParse(data: unknown) {
return invitationsSchema.safeParse(data);
}
}

View file

@ -0,0 +1,64 @@
import { bannerNameSchema } from '../../../schemas/bannerName.schema';
import { DismissBannerRequestDto } from '../dismiss-banner-request.dto';
describe('DismissBannerRequestDto', () => {
describe('Valid requests', () => {
test.each(
bannerNameSchema.options.map((banner) => ({
name: `valid banner: ${banner}`,
request: { banner },
})),
)('should validate $name', ({ request }) => {
const result = DismissBannerRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid banner string',
request: {
banner: 'not-a-valid-banner',
},
expectedErrorPath: ['banner'],
},
{
name: 'non-string banner',
request: {
banner: 123,
},
expectedErrorPath: ['banner'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = DismissBannerRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
describe('Optional banner', () => {
test('should validate empty request', () => {
const result = DismissBannerRequestDto.safeParse({});
expect(result.success).toBe(true);
});
});
describe('Exhaustive banner name check', () => {
test('should have all banner names defined', () => {
const expectedBanners = [
'V1',
'TRIAL_OVER',
'TRIAL',
'NON_PRODUCTION_LICENSE',
'EMAIL_CONFIRMATION',
];
expect(bannerNameSchema.options).toEqual(expectedBanners);
});
});
});

View file

@ -0,0 +1,93 @@
import { OwnerSetupRequestDto } from '../owner-setup-request.dto';
describe('OwnerSetupRequestDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'complete valid setup request',
request: {
email: 'owner@example.com',
firstName: 'John',
lastName: 'Doe',
password: 'SecurePassword123',
},
},
])('should validate $name', ({ request }) => {
const result = OwnerSetupRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid email',
request: {
email: 'invalid-email',
firstName: 'John',
lastName: 'Doe',
password: 'SecurePassword123',
},
expectedErrorPath: ['email'],
},
{
name: 'missing first name',
request: {
email: 'owner@example.com',
firstName: '',
lastName: 'Doe',
password: 'SecurePassword123',
},
expectedErrorPath: ['firstName'],
},
{
name: 'missing last name',
request: {
email: 'owner@example.com',
firstName: 'John',
lastName: '',
password: 'SecurePassword123',
},
expectedErrorPath: ['lastName'],
},
{
name: 'password too short',
request: {
email: 'owner@example.com',
firstName: 'John',
lastName: 'Doe',
password: 'short',
},
expectedErrorPath: ['password'],
},
{
name: 'password without number',
request: {
email: 'owner@example.com',
firstName: 'John',
lastName: 'Doe',
password: 'NoNumberPassword',
},
expectedErrorPath: ['password'],
},
{
name: 'password without uppercase letter',
request: {
email: 'owner@example.com',
firstName: 'John',
lastName: 'Doe',
password: 'nouppercasepassword123',
},
expectedErrorPath: ['password'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = OwnerSetupRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,7 @@
import { Z } from 'zod-class';
import { bannerNameSchema } from '../../schemas/bannerName.schema';
export class DismissBannerRequestDto extends Z.class({
banner: bannerNameSchema.optional(),
}) {}

View file

@ -0,0 +1,11 @@
import { z } from 'zod';
import { Z } from 'zod-class';
import { passwordSchema } from '../../schemas/password.schema';
export class OwnerSetupRequestDto extends Z.class({
email: z.string().email(),
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
password: passwordSchema,
}) {}

View file

@ -0,0 +1,114 @@
import { ChangePasswordRequestDto } from '../change-password-request.dto';
describe('ChangePasswordRequestDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'valid password reset with token',
request: {
token: 'valid-reset-token-with-sufficient-length',
password: 'newSecurePassword123',
},
},
{
name: 'valid password reset with MFA code',
request: {
token: 'another-valid-reset-token',
password: 'newSecurePassword123',
mfaCode: '123456',
},
},
])('should validate $name', ({ request }) => {
const result = ChangePasswordRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing token',
request: { password: 'newSecurePassword123' },
expectedErrorPath: ['token'],
},
{
name: 'empty token',
request: { token: '', password: 'newSecurePassword123' },
expectedErrorPath: ['token'],
},
{
name: 'short token',
request: { token: 'short', password: 'newSecurePassword123' },
expectedErrorPath: ['token'],
},
{
name: 'missing password',
request: { token: 'valid-reset-token' },
expectedErrorPath: ['password'],
},
{
name: 'password too short',
request: {
token: 'valid-reset-token',
password: 'short',
},
expectedErrorPath: ['password'],
},
{
name: 'password too long',
request: {
token: 'valid-reset-token',
password: 'a'.repeat(65),
},
expectedErrorPath: ['password'],
},
{
name: 'password without number',
request: {
token: 'valid-reset-token',
password: 'NoNumberPassword',
},
expectedErrorPath: ['password'],
},
{
name: 'password without uppercase letter',
request: {
token: 'valid-reset-token',
password: 'nouppercasepassword123',
},
expectedErrorPath: ['password'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = ChangePasswordRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
describe('Edge cases', () => {
test('should handle optional MFA code correctly', () => {
const validRequest = {
token: 'valid-reset-token',
password: 'newSecurePassword123',
mfaCode: undefined,
};
const result = ChangePasswordRequestDto.safeParse(validRequest);
expect(result.success).toBe(true);
});
test('should handle token with special characters', () => {
const validRequest = {
token: 'valid-reset-token-with-special-!@#$%^&*()_+',
password: 'newSecurePassword123',
};
const result = ChangePasswordRequestDto.safeParse(validRequest);
expect(result.success).toBe(true);
});
});
});
});

View file

@ -0,0 +1,47 @@
import { ForgotPasswordRequestDto } from '../forgot-password-request.dto';
describe('ForgotPasswordRequestDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'valid email',
request: { email: 'test@example.com' },
},
{
name: 'email with subdomain',
request: { email: 'user@sub.example.com' },
},
])('should validate $name', ({ request }) => {
const result = ForgotPasswordRequestDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'invalid email format',
request: { email: 'invalid-email' },
expectedErrorPath: ['email'],
},
{
name: 'missing email',
request: {},
expectedErrorPath: ['email'],
},
{
name: 'empty email',
request: { email: '' },
expectedErrorPath: ['email'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = ForgotPasswordRequestDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

View file

@ -0,0 +1,42 @@
import { ResolvePasswordTokenQueryDto } from '../resolve-password-token-query.dto';
describe('ResolvePasswordTokenQueryDto', () => {
describe('Valid requests', () => {
test.each([
{
name: 'valid token',
request: { token: 'valid-reset-token' },
},
{
name: 'long token',
request: { token: 'x'.repeat(50) },
},
])('should validate $name', ({ request }) => {
const result = ResolvePasswordTokenQueryDto.safeParse(request);
expect(result.success).toBe(true);
});
});
describe('Invalid requests', () => {
test.each([
{
name: 'missing token',
request: {},
expectedErrorPath: ['token'],
},
{
name: 'empty token',
request: { token: '' },
expectedErrorPath: ['token'],
},
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = ResolvePasswordTokenQueryDto.safeParse(request);
expect(result.success).toBe(false);
if (expectedErrorPath) {
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
}
});
});
});

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