mirror of
https://github.com/n8n-io/n8n.git
synced 2025-02-21 02:56:40 -08:00
Merge remote-tracking branch 'origin/master' into pay-1852-public-api-delete-users-from-project
This commit is contained in:
commit
e9d96f55e7
6
.github/workflows/chromatic.yml
vendored
6
.github/workflows/chromatic.yml
vendored
|
@ -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 }}
|
||||
|
|
1
.github/workflows/ci-master.yml
vendored
1
.github/workflows/ci-master.yml
vendored
|
@ -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 }}
|
||||
|
||||
|
|
2
.github/workflows/ci-postgres-mysql.yml
vendored
2
.github/workflows/ci-postgres-mysql.yml
vendored
|
@ -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
|
||||
|
|
4
.github/workflows/test-workflows.yml
vendored
4
.github/workflows/test-workflows.yml
vendored
|
@ -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
|
||||
|
|
8
.github/workflows/units-tests-reusable.yml
vendored
8
.github/workflows/units-tests-reusable.yml
vendored
|
@ -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 }}
|
||||
|
|
117
CHANGELOG.md
117
CHANGELOG.md
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
60
codecov.yml
Normal 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
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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(
|
||||
`.add-input-endpoint[data-jtk-scope-${endpointType}][data-endpoint-name="${nodeName}"]`,
|
||||
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
|
||||
.get('.jtk-connector')
|
||||
.filter(`[data-source-node="${source}"][data-target-node="${target}"]`)
|
||||
.eq(0);
|
||||
return cy.ifCanvasVersion(
|
||||
() =>
|
||||
cy
|
||||
.get('.jtk-connector')
|
||||
.filter(`[data-source-node="${source}"][data-target-node="${target}"]`)
|
||||
.eq(0),
|
||||
() =>
|
||||
cy
|
||||
.getByTestId('edge')
|
||||
.filter(`[data-source-node-name="${source}"][data-target-node-name="${target}"]`)
|
||||
.eq(0),
|
||||
);
|
||||
}
|
||||
|
||||
export function 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();
|
||||
}
|
||||
getConnectionBySourceAndTarget(parentNodeName, nodeName).should('exist');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
15
cypress/composables/workflowsPage.ts
Normal file
15
cypress/composables/workflowsPage.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Getters
|
||||
*/
|
||||
|
||||
export function getWorkflowsPageUrl() {
|
||||
return '/home/workflows';
|
||||
}
|
||||
|
||||
/**
|
||||
* Actions
|
||||
*/
|
||||
|
||||
export function visitWorkflowsPage() {
|
||||
cy.visit(getWorkflowsPageUrl());
|
||||
}
|
|
@ -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 }}');
|
||||
|
|
|
@ -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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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', '😀');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
||||
nodeCreatorFeature.getters.canvasAddButton().should('not.be.visible');
|
||||
nodeCreatorFeature.getters.nodeCreator().should('not.exist');
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
nodeCreatorFeature.getters.canvasAddButton().should('not.be.visible');
|
||||
nodeCreatorFeature.getters.nodeCreator().should('not.exist');
|
||||
// TODO: Replace once we have canvas feature utils
|
||||
cy.get('div').contains('Add first step').should('be.hidden');
|
||||
},
|
||||
() => {
|
||||
nodeCreatorFeature.getters.canvasAddButton().should('not.exist');
|
||||
nodeCreatorFeature.getters.nodeCreator().should('not.exist');
|
||||
// TODO: Replace once we have canvas feature utils
|
||||
cy.get('div').contains('Add first step').should('not.exist');
|
||||
},
|
||||
);
|
||||
|
||||
// TODO: Replace once we have canvas feature utils
|
||||
cy.get('div').contains('Add first step').should('be.hidden');
|
||||
nodeCreatorFeature.actions.openNodeCreator();
|
||||
nodeCreatorFeature.getters.nodeCreator().contains('What happens next?').should('be.visible');
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
242
cypress/e2e/48-subworkflow-inputs.cy.ts
Normal file
242
cypress/e2e/48-subworkflow-inputs.cy.ts
Normal 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';
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
WorkflowPage.getters.executeWorkflowButton().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');
|
||||
|
|
69
cypress/fixtures/Test_Subworkflow-Inputs.json
Normal file
69
cypress/fixtures/Test_Subworkflow-Inputs.json
Normal 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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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": {
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
import { BasePage } from './base';
|
||||
|
||||
export class BannerStack extends BasePage {
|
||||
getters = {
|
||||
banner: () => cy.getByTestId('banner-stack'),
|
||||
};
|
||||
|
||||
actions = {};
|
||||
}
|
|
@ -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 = {};
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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 }),
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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 }),
|
||||
|
|
|
@ -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 });
|
||||
},
|
||||
|
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
import { BasePage } from './base';
|
||||
|
||||
export class SettingsUsagePage extends BasePage {
|
||||
url = '/settings/usage';
|
||||
|
||||
getters = {};
|
||||
|
||||
actions = {};
|
||||
}
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
import { BasePage } from './base';
|
||||
|
||||
export class SettingsPage extends BasePage {
|
||||
url = '/settings';
|
||||
|
||||
getters = {
|
||||
menuItems: () => cy.getByTestId('menu-item'),
|
||||
};
|
||||
|
||||
actions = {};
|
||||
}
|
|
@ -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),
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
import { BasePage } from './base';
|
||||
|
||||
export class WorkflowHistoryPage extends BasePage {
|
||||
getters = {
|
||||
workflowHistoryCloseButton: () => cy.getByTestId('workflow-history-close-button'),
|
||||
};
|
||||
}
|
|
@ -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'),
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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 \
|
||||
|
|
|
@ -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 \
|
||||
|
|
|
@ -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",
|
||||
|
|
13
package.json
13
package.json
|
@ -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",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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']);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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(),
|
||||
}) {}
|
53
packages/@n8n/api-types/src/dto/ai/ai-ask-request.dto.ts
Normal file
53
packages/@n8n/api-types/src/dto/ai/ai-ask-request.dto.ts
Normal 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 {}
|
10
packages/@n8n/api-types/src/dto/ai/ai-chat-request.dto.ts
Normal file
10
packages/@n8n/api-types/src/dto/ai/ai-chat-request.dto.ts
Normal 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 {}
|
|
@ -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(),
|
||||
}) {}
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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(),
|
||||
}) {}
|
|
@ -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(),
|
||||
}) {}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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(),
|
||||
}) {}
|
|
@ -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'),
|
||||
}) {}
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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(),
|
||||
}) {}
|
|
@ -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
|
||||
>,
|
||||
}) {}
|
|
@ -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>,
|
||||
}) {}
|
|
@ -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(),
|
||||
}) {}
|
|
@ -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(),
|
||||
}) {}
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
}) {}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,7 @@
|
|||
import { Z } from 'zod-class';
|
||||
|
||||
import { bannerNameSchema } from '../../schemas/bannerName.schema';
|
||||
|
||||
export class DismissBannerRequestDto extends Z.class({
|
||||
banner: bannerNameSchema.optional(),
|
||||
}) {}
|
|
@ -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,
|
||||
}) {}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
Loading…
Reference in a new issue