mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-29 21:31:07 -08:00
Merge branch 'master' into node-1929-summarize-node-throws-an-error-that-should-be-a-warning
This commit is contained in:
commit
6f656d3b6b
3
.github/workflows/chromatic.yml
vendored
3
.github/workflows/chromatic.yml
vendored
|
@ -8,13 +8,14 @@ on:
|
|||
types: [submitted]
|
||||
|
||||
concurrency:
|
||||
group: chromatic-${{ github.event.pull_request.number || github.ref }}
|
||||
group: chromatic-${{ github.event.pull_request.number || github.ref }}-${{github.event.review.state}}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
get-metadata:
|
||||
name: Get Metadata
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.review.state == 'approved'
|
||||
steps:
|
||||
- name: Check out current commit
|
||||
uses: actions/checkout@v4
|
||||
|
|
7
.github/workflows/e2e-reusable.yml
vendored
7
.github/workflows/e2e-reusable.yml
vendored
|
@ -41,11 +41,6 @@ on:
|
|||
description: 'PR number to run tests for.'
|
||||
required: false
|
||||
type: number
|
||||
node_view_version:
|
||||
description: 'Node View version to run tests with.'
|
||||
required: false
|
||||
default: '1'
|
||||
type: string
|
||||
secrets:
|
||||
CYPRESS_RECORD_KEY:
|
||||
description: 'Cypress record key.'
|
||||
|
@ -165,7 +160,7 @@ jobs:
|
|||
spec: '${{ inputs.spec }}'
|
||||
env:
|
||||
NODE_OPTIONS: --dns-result-order=ipv4first
|
||||
CYPRESS_NODE_VIEW_VERSION: ${{ inputs.node_view_version }}
|
||||
CYPRESS_NODE_VIEW_VERSION: 2
|
||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
E2E_TESTS: true
|
||||
|
|
3
.github/workflows/e2e-tests-pr.yml
vendored
3
.github/workflows/e2e-tests-pr.yml
vendored
|
@ -5,13 +5,14 @@ on:
|
|||
types: [submitted]
|
||||
|
||||
concurrency:
|
||||
group: e2e-${{ github.event.pull_request.number || github.ref }}
|
||||
group: e2e-${{ github.event.pull_request.number || github.ref }}-${{github.event.review.state}}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
get-metadata:
|
||||
name: Get Metadata
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.review.state == 'approved'
|
||||
steps:
|
||||
- name: Check out current commit
|
||||
uses: actions/checkout@v4
|
||||
|
|
95
CHANGELOG.md
95
CHANGELOG.md
|
@ -1,3 +1,98 @@
|
|||
# [1.76.0](https://github.com/n8n-io/n8n/compare/n8n@1.75.0...n8n@1.76.0) (2025-01-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Align saving behavior in `workflowExecuteAfter` hooks ([#12731](https://github.com/n8n-io/n8n/issues/12731)) ([9d76210](https://github.com/n8n-io/n8n/commit/9d76210a570e025d01d1f6596667abf40fbd8d12))
|
||||
* **core:** AugmentObject should handle the constructor property correctly ([#12744](https://github.com/n8n-io/n8n/issues/12744)) ([36bc164](https://github.com/n8n-io/n8n/commit/36bc164da486f2e2d05091b457b8eea6521ca22e))
|
||||
* **core:** Fix keyboard shortcuts for non-ansi layouts ([#12672](https://github.com/n8n-io/n8n/issues/12672)) ([4c8193f](https://github.com/n8n-io/n8n/commit/4c8193fedc2e3967c9a06c0652483128df509653))
|
||||
* **core:** Fix license CLI commands showing incorrect renewal setting ([#12759](https://github.com/n8n-io/n8n/issues/12759)) ([024ada8](https://github.com/n8n-io/n8n/commit/024ada822c1bc40958e594bb08707cf77d3397ec))
|
||||
* **core:** Fix license initialization failure on startup ([#12737](https://github.com/n8n-io/n8n/issues/12737)) ([ac2f647](https://github.com/n8n-io/n8n/commit/ac2f6476c114f51fafb9b7b66e41e0c87f4a1bf6))
|
||||
* **core:** Recover successful data-less executions ([#12720](https://github.com/n8n-io/n8n/issues/12720)) ([a39b8bd](https://github.com/n8n-io/n8n/commit/a39b8bd32be50c8323e415f820b25b4bcb81d960))
|
||||
* **core:** Remove run data of utility nodes for partial executions v2 ([#12673](https://github.com/n8n-io/n8n/issues/12673)) ([b66a9dc](https://github.com/n8n-io/n8n/commit/b66a9dc8fb6f7b19122cacbb7e2f86b4c921c3fb))
|
||||
* **core:** Sync `hookFunctionsSave` and `hookFunctionsSaveWorker` ([#12740](https://github.com/n8n-io/n8n/issues/12740)) ([d410b8f](https://github.com/n8n-io/n8n/commit/d410b8f5a7e99658e1e8dcb2e02901bd01ce9c59))
|
||||
* **core:** Update isDocker check to return true on kubernetes/containerd ([#12603](https://github.com/n8n-io/n8n/issues/12603)) ([c55dac6](https://github.com/n8n-io/n8n/commit/c55dac66ed97a2317d4c696c3b505790ec5d72fe))
|
||||
* **editor:** Add unicode code points to expression language for emoji ([#12633](https://github.com/n8n-io/n8n/issues/12633)) ([819ebd0](https://github.com/n8n-io/n8n/commit/819ebd058d1d60b3663d92b4a652728da7134a3b))
|
||||
* **editor:** Correct missing whitespace in JSON output ([#12677](https://github.com/n8n-io/n8n/issues/12677)) ([b098b19](https://github.com/n8n-io/n8n/commit/b098b19c7f0e3a9848c3fcfa012999050f2d3c7a))
|
||||
* **editor:** Defer crypto.randomUUID call in CodeNodeEditor ([#12630](https://github.com/n8n-io/n8n/issues/12630)) ([58f6532](https://github.com/n8n-io/n8n/commit/58f6532630bacd288d3c0a79b40150f465898419))
|
||||
* **editor:** Fix Code node bug erasing and overwriting code when switching between nodes ([#12637](https://github.com/n8n-io/n8n/issues/12637)) ([02d953d](https://github.com/n8n-io/n8n/commit/02d953db34ec4e44977a8ca908628b62cca82fde))
|
||||
* **editor:** Fix execution list hover & selection colour in dark mode ([#12628](https://github.com/n8n-io/n8n/issues/12628)) ([95c40c0](https://github.com/n8n-io/n8n/commit/95c40c02cb8fef77cf633cf5aec08e98746cff36))
|
||||
* **editor:** Fix JsonEditor with expressions ([#12739](https://github.com/n8n-io/n8n/issues/12739)) ([56c93ca](https://github.com/n8n-io/n8n/commit/56c93caae026738c1c0bebb4187b238e34a330f6))
|
||||
* **editor:** Fix navbar height flickering during load ([#12738](https://github.com/n8n-io/n8n/issues/12738)) ([a96b3f0](https://github.com/n8n-io/n8n/commit/a96b3f0091798a52bb33107b919b5d8287ba7506))
|
||||
* **editor:** Open chat when executing agent node in canvas v2 ([#12617](https://github.com/n8n-io/n8n/issues/12617)) ([457edd9](https://github.com/n8n-io/n8n/commit/457edd99bb853d8ccf3014605d5823933f3c0bc6))
|
||||
* **editor:** Partial execution of a workflow with manual chat trigger ([#12662](https://github.com/n8n-io/n8n/issues/12662)) ([2f81b29](https://github.com/n8n-io/n8n/commit/2f81b29d341535b512df0aa01b25a91d109f113f))
|
||||
* **editor:** Show connector label above the line when it's straight ([#12622](https://github.com/n8n-io/n8n/issues/12622)) ([c97bd48](https://github.com/n8n-io/n8n/commit/c97bd48a77643b9c2a5d7218e21b957af15cee0b))
|
||||
* **editor:** Show run workflow button when chat trigger has pinned data ([#12616](https://github.com/n8n-io/n8n/issues/12616)) ([da8aafc](https://github.com/n8n-io/n8n/commit/da8aafc0e3a1b5d862f0723d0d53d2c38bcaebc3))
|
||||
* **editor:** Update workflow re-initialization to use query parameter ([#12650](https://github.com/n8n-io/n8n/issues/12650)) ([982131a](https://github.com/n8n-io/n8n/commit/982131a75a32f741c120156826c303989aac189c))
|
||||
* **Execute Workflow Node:** Pass binary data to sub-workflow ([#12635](https://github.com/n8n-io/n8n/issues/12635)) ([e9c152e](https://github.com/n8n-io/n8n/commit/e9c152e369a4c2762bd8e6ad17eaa704bb3771bb))
|
||||
* **Google Gemini Chat Model Node:** Add base URL support for Google Gemini Chat API ([#12643](https://github.com/n8n-io/n8n/issues/12643)) ([14f4bc7](https://github.com/n8n-io/n8n/commit/14f4bc769027789513808b4000444edf99dc5d1c))
|
||||
* **GraphQL Node:** Change default request format to json instead of graphql ([#11346](https://github.com/n8n-io/n8n/issues/11346)) ([c7c122f](https://github.com/n8n-io/n8n/commit/c7c122f9173df824cc1b5ab864333bffd0d31f82))
|
||||
* **Jira Software Node:** Get custom fields(RLC) in update operation for server deployment type ([#12719](https://github.com/n8n-io/n8n/issues/12719)) ([353df79](https://github.com/n8n-io/n8n/commit/353df7941117e20547cd4f3fc514979a54619720))
|
||||
* **n8n Form Node:** Remove the ability to change the formatting of dates ([#12666](https://github.com/n8n-io/n8n/issues/12666)) ([14904ff](https://github.com/n8n-io/n8n/commit/14904ff77951fef23eb789a43947492a4cd3fa20))
|
||||
* **OpenAI Chat Model Node:** Fix loading of custom models when using custom credential URL ([#12634](https://github.com/n8n-io/n8n/issues/12634)) ([7cc553e](https://github.com/n8n-io/n8n/commit/7cc553e3b277a16682bfca1ea08cb98178e38580))
|
||||
* **OpenAI Chat Model Node:** Restore default model value ([#12745](https://github.com/n8n-io/n8n/issues/12745)) ([d1b6692](https://github.com/n8n-io/n8n/commit/d1b6692736182fa2eab768ba3ad0adb8504ebbbd))
|
||||
* **Postgres Chat Memory Node:** Do not terminate the connection pool ([#12674](https://github.com/n8n-io/n8n/issues/12674)) ([e7f00bc](https://github.com/n8n-io/n8n/commit/e7f00bcb7f2dce66ca07a9322d50f96356c1a43d))
|
||||
* **Postgres Node:** Allow using composite key in upsert queries ([#12639](https://github.com/n8n-io/n8n/issues/12639)) ([83ce3a9](https://github.com/n8n-io/n8n/commit/83ce3a90963ba76601234f4314363a8ccc310f0f))
|
||||
* **Wait Node:** Fix for hasNextPage in waiting forms ([#12636](https://github.com/n8n-io/n8n/issues/12636)) ([652b8d1](https://github.com/n8n-io/n8n/commit/652b8d170b9624d47b5f2d8d679c165cc14ea548))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add credential only node for Microsoft Azure Monitor ([#12645](https://github.com/n8n-io/n8n/issues/12645)) ([6ef8882](https://github.com/n8n-io/n8n/commit/6ef8882a108c672ab097c9dd1c590d4e9e7f3bcc))
|
||||
* Add Miro credential only node ([#12746](https://github.com/n8n-io/n8n/issues/12746)) ([5b29086](https://github.com/n8n-io/n8n/commit/5b29086e2f9b7f638fac4440711f673438e57492))
|
||||
* Add SSM endpoint to AWS credentials ([#12212](https://github.com/n8n-io/n8n/issues/12212)) ([565c7b8](https://github.com/n8n-io/n8n/commit/565c7b8b9cfd3e10f6a2c60add96fea4c4d95d33))
|
||||
* **core:** Enable task runner by default ([#12726](https://github.com/n8n-io/n8n/issues/12726)) ([9e2a01a](https://github.com/n8n-io/n8n/commit/9e2a01aeaf36766a1cf7a1d9a4d6e02f45739bd3))
|
||||
* **editor:** Force final canvas v2 migration and remove switcher from UI ([#12717](https://github.com/n8n-io/n8n/issues/12717)) ([29335b9](https://github.com/n8n-io/n8n/commit/29335b9b6acf97c817bea70688e8a2786fbd8889))
|
||||
* **editor:** VariablesView Reskin - Add Filters for missing values ([#12611](https://github.com/n8n-io/n8n/issues/12611)) ([1eeb788](https://github.com/n8n-io/n8n/commit/1eeb788d327287d21eab7ad6f2156453ab7642c7))
|
||||
* **Jira Software Node:** Personal Access Token credential type ([#11038](https://github.com/n8n-io/n8n/issues/11038)) ([1c7a38f](https://github.com/n8n-io/n8n/commit/1c7a38f6bab108daa47401cd98c185590bf299a8))
|
||||
* **n8n Form Trigger Node:** Form Improvements ([#12590](https://github.com/n8n-io/n8n/issues/12590)) ([f167578](https://github.com/n8n-io/n8n/commit/f167578b3251e553a4d000e731e1bb60348916ad))
|
||||
* Synchronize deletions when pulling from source control ([#12170](https://github.com/n8n-io/n8n/issues/12170)) ([967ee4b](https://github.com/n8n-io/n8n/commit/967ee4b89b94b92fc3955c56bf4c9cca0bd64eac))
|
||||
|
||||
|
||||
|
||||
# [1.75.0](https://github.com/n8n-io/n8n/compare/n8n@1.74.0...n8n@1.75.0) (2025-01-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** AugmentObject should check for own propeties correctly ([#12534](https://github.com/n8n-io/n8n/issues/12534)) ([0cdf393](https://github.com/n8n-io/n8n/commit/0cdf39374305e6bbcedb047db7d3756168e6e89e))
|
||||
* **core:** Disallow code generation in task runner ([#12522](https://github.com/n8n-io/n8n/issues/12522)) ([35b6180](https://github.com/n8n-io/n8n/commit/35b618098b7d23e272bf77b55c172dbe531c821f))
|
||||
* **core:** Fix node exclusion on the frontend types ([#12544](https://github.com/n8n-io/n8n/issues/12544)) ([b2cbed9](https://github.com/n8n-io/n8n/commit/b2cbed9865888f6f3bc528984d4091d86a88f0d6))
|
||||
* **core:** Fix orchestration flow with expired license ([#12444](https://github.com/n8n-io/n8n/issues/12444)) ([ecff3b7](https://github.com/n8n-io/n8n/commit/ecff3b732a028d7225bfbed4ffc65dc20c4ed608))
|
||||
* **core:** Fix Sentry error reporting on task runners ([#12495](https://github.com/n8n-io/n8n/issues/12495)) ([88c0838](https://github.com/n8n-io/n8n/commit/88c0838dd72f11646bdb3586223d6c16631cccab))
|
||||
* **core:** Improve cyclic dependency check in the DI container ([#12600](https://github.com/n8n-io/n8n/issues/12600)) ([c3c4a20](https://github.com/n8n-io/n8n/commit/c3c4a200024fb08afb9380357d1490c6707c5ec3))
|
||||
* **core:** Only show personal credentials in the personal space ([#12433](https://github.com/n8n-io/n8n/issues/12433)) ([8a42d55](https://github.com/n8n-io/n8n/commit/8a42d55d91f4a37fff5669d52d52428b3a4ddd44))
|
||||
* **core:** Prefix package name in `supportedNodes` on generated types as well ([#12514](https://github.com/n8n-io/n8n/issues/12514)) ([4a1a999](https://github.com/n8n-io/n8n/commit/4a1a9993624c92dd81f5418f9268cb93878069ab))
|
||||
* **core:** Prevent prototype pollution in task runner ([#12588](https://github.com/n8n-io/n8n/issues/12588)) ([bdf266c](https://github.com/n8n-io/n8n/commit/bdf266cf55032d05641b20dce8804412dc93b6d5))
|
||||
* **core:** Prevent prototype pollution of internal classes in task runner ([#12610](https://github.com/n8n-io/n8n/issues/12610)) ([eceee7f](https://github.com/n8n-io/n8n/commit/eceee7f3f8899d200b1c5720087cc494eec22e6a))
|
||||
* **core:** Use timing safe function to compare runner auth tokens ([#12485](https://github.com/n8n-io/n8n/issues/12485)) ([8fab98f](https://github.com/n8n-io/n8n/commit/8fab98f3f1f767d05825d24cbf155d56375fdb3e))
|
||||
* **core:** Validate values which are intentionally 0 ([#12382](https://github.com/n8n-io/n8n/issues/12382)) ([562506e](https://github.com/n8n-io/n8n/commit/562506e92aeb26423145801bff80037e5ce2ac46))
|
||||
* Don't break oauth credentials when updating them and allow fixing broken oauth credentials by repeating the authorization flow ([#12563](https://github.com/n8n-io/n8n/issues/12563)) ([73897c7](https://github.com/n8n-io/n8n/commit/73897c7662a432834eb6f9d0f9ace8d986c1acb5))
|
||||
* **editor:** Don't show toolsUnused notice if run had errors ([#12529](https://github.com/n8n-io/n8n/issues/12529)) ([3ec5b28](https://github.com/n8n-io/n8n/commit/3ec5b2850c47057032e61c2acdbdfc1dcdd931f7))
|
||||
* **editor:** Ensure proper "AI Template" URL construction in node creator ([#12566](https://github.com/n8n-io/n8n/issues/12566)) ([13bf69f](https://github.com/n8n-io/n8n/commit/13bf69f75c67bc37a37013e776525768676a4b88))
|
||||
* **editor:** Fix NDV resize handle and scrollbar overlapping ([#12509](https://github.com/n8n-io/n8n/issues/12509)) ([c28f302](https://github.com/n8n-io/n8n/commit/c28f302c2f863bd7aa73ad52e5d040f927e33220))
|
||||
* **editor:** Fix parameter input validation ([#12532](https://github.com/n8n-io/n8n/issues/12532)) ([6711cbc](https://github.com/n8n-io/n8n/commit/6711cbcc641a2fc70f5c15a7e2dcc640a3f98b66))
|
||||
* **editor:** Fix selection rectangle context menu on new canvas ([#12584](https://github.com/n8n-io/n8n/issues/12584)) ([c8e3c53](https://github.com/n8n-io/n8n/commit/c8e3c5399efde93486c1dd5c373cb2c5ff8a0691))
|
||||
* **editor:** Fix the `openselectivenodecreator` custom action on new canvas ([#12580](https://github.com/n8n-io/n8n/issues/12580)) ([2110e9a](https://github.com/n8n-io/n8n/commit/2110e9a0513b8c36beb85302e0d38a2658ea5d6e))
|
||||
* **editor:** Fix workflow initilisation for test definition routes & add unit tests ([#12507](https://github.com/n8n-io/n8n/issues/12507)) ([2775f61](https://github.com/n8n-io/n8n/commit/2775f617ae5c267c0a1ce7a54d05d4077cdbc0f7))
|
||||
* **editor:** Make clicking item in RLC work the first time on small screens ([#12585](https://github.com/n8n-io/n8n/issues/12585)) ([479933f](https://github.com/n8n-io/n8n/commit/479933fbd5c88e783827960e018abb979de8a039))
|
||||
* **editor:** Make sure code editors work correctly in fullscreen ([#12597](https://github.com/n8n-io/n8n/issues/12597)) ([aa1f3a7](https://github.com/n8n-io/n8n/commit/aa1f3a7d989883d55df3777775b8d7d336f6e3b7))
|
||||
* **editor:** Override selected nodes on single click without Meta/Ctrl key ([#12549](https://github.com/n8n-io/n8n/issues/12549)) ([02c2d5e](https://github.com/n8n-io/n8n/commit/02c2d5e71d15b9292fddd585f47bd8334da468c5))
|
||||
* **editor:** Show NDV errors when opening existing nodes with errors ([#12567](https://github.com/n8n-io/n8n/issues/12567)) ([bee7267](https://github.com/n8n-io/n8n/commit/bee7267fe38ab12a79fa4ec0e775f45d98d48aa5))
|
||||
* **editor:** Swap Activate/Deactivate texts in FloatingToolbar ([#12526](https://github.com/n8n-io/n8n/issues/12526)) ([44679b4](https://github.com/n8n-io/n8n/commit/44679b42aa1e14bc7069bee47d0a91ca84b1dba4))
|
||||
* **editor:** Update filter and feedback for source control ([#12504](https://github.com/n8n-io/n8n/issues/12504)) ([865fc21](https://github.com/n8n-io/n8n/commit/865fc21276727e8d88ccee0355147904b81c4421))
|
||||
* **editor:** Update selected node when navigating via flowing nodes ([#12581](https://github.com/n8n-io/n8n/issues/12581)) ([88659d8](https://github.com/n8n-io/n8n/commit/88659d8a2901786c894902e19466f395bcdaab8e))
|
||||
* **Google Calendar Node:** Updates and fixes ([#10715](https://github.com/n8n-io/n8n/issues/10715)) ([7227a29](https://github.com/n8n-io/n8n/commit/7227a29845fd178ced4d281597c62e7a03245456))
|
||||
* **Spotify Node:** Fix issue with null values breaking the response ([#12080](https://github.com/n8n-io/n8n/issues/12080)) ([a56a462](https://github.com/n8n-io/n8n/commit/a56a46259d257003c813103578260d625b3f17dd))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **editor:** Make node credential select searchable ([#12497](https://github.com/n8n-io/n8n/issues/12497)) ([91277c4](https://github.com/n8n-io/n8n/commit/91277c44f1cf3f334b3b50d47d7dcc79b11c7c63))
|
||||
* **editor:** Persist sidebar collapsed status preference ([#12505](https://github.com/n8n-io/n8n/issues/12505)) ([dba7d46](https://github.com/n8n-io/n8n/commit/dba7d46f3ec91d26a597a50dede7b6ca292c728f))
|
||||
|
||||
|
||||
|
||||
# [1.74.0](https://github.com/n8n-io/n8n/compare/n8n@1.73.0...n8n@1.74.0) (2025-01-08)
|
||||
|
||||
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
* Getters
|
||||
*/
|
||||
|
||||
import { clearNotifications } from '../../pages/notifications';
|
||||
|
||||
export function getCredentialConnectionParameterInputs() {
|
||||
return cy.getByTestId('credential-connection-parameter');
|
||||
}
|
||||
|
@ -55,5 +57,6 @@ export function setCredentialValues(values: Record<string, string>, save = true)
|
|||
if (save) {
|
||||
saveCredential();
|
||||
closeCredentialModal();
|
||||
clearNotifications();
|
||||
}
|
||||
}
|
||||
|
|
81
cypress/composables/webhooks.ts
Normal file
81
cypress/composables/webhooks.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
import { BACKEND_BASE_URL } from '../constants';
|
||||
import { NDV, WorkflowPage } from '../pages';
|
||||
import { getVisibleSelect } from '../utils';
|
||||
|
||||
export const waitForWebhook = 500;
|
||||
|
||||
export interface SimpleWebhookCallOptions {
|
||||
method: string;
|
||||
webhookPath: string;
|
||||
responseCode?: number;
|
||||
respondWith?: string;
|
||||
executeNow?: boolean;
|
||||
responseData?: string;
|
||||
authentication?: string;
|
||||
}
|
||||
|
||||
const workflowPage = new WorkflowPage();
|
||||
const ndv = new NDV();
|
||||
|
||||
export const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
|
||||
const {
|
||||
authentication,
|
||||
method,
|
||||
webhookPath,
|
||||
responseCode,
|
||||
respondWith,
|
||||
responseData,
|
||||
executeNow = true,
|
||||
} = options;
|
||||
|
||||
workflowPage.actions.addInitialNodeToCanvas('Webhook');
|
||||
workflowPage.actions.openNode('Webhook');
|
||||
|
||||
cy.getByTestId('parameter-input-httpMethod').click();
|
||||
getVisibleSelect().find('.option-headline').contains(method).click();
|
||||
cy.getByTestId('parameter-input-path')
|
||||
.find('.parameter-input')
|
||||
.find('input')
|
||||
.clear()
|
||||
.type(webhookPath);
|
||||
|
||||
if (authentication) {
|
||||
cy.getByTestId('parameter-input-authentication').click();
|
||||
getVisibleSelect().find('.option-headline').contains(authentication).click();
|
||||
}
|
||||
|
||||
if (responseCode) {
|
||||
cy.get('.param-options').click();
|
||||
getVisibleSelect().contains('Response Code').click();
|
||||
cy.get('.parameter-item-wrapper > .parameter-input-list-wrapper').children().click();
|
||||
getVisibleSelect().contains('201').click();
|
||||
}
|
||||
|
||||
if (respondWith) {
|
||||
cy.getByTestId('parameter-input-responseMode').click();
|
||||
getVisibleSelect().find('.option-headline').contains(respondWith).click();
|
||||
}
|
||||
|
||||
if (responseData) {
|
||||
cy.getByTestId('parameter-input-responseData').click();
|
||||
getVisibleSelect().find('.option-headline').contains(responseData).click();
|
||||
}
|
||||
|
||||
const callEndpoint = (fn: (response: Cypress.Response<unknown>) => void) => {
|
||||
cy.request(method, `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then(fn);
|
||||
};
|
||||
|
||||
if (executeNow) {
|
||||
ndv.actions.execute();
|
||||
cy.wait(waitForWebhook);
|
||||
|
||||
callEndpoint((response) => {
|
||||
expect(response.status).to.eq(200);
|
||||
ndv.getters.outputPanel().contains('headers');
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
callEndpoint,
|
||||
};
|
||||
};
|
|
@ -1,6 +1,7 @@
|
|||
import { getManualChatModal } from './modals/chat-modal';
|
||||
import { clickGetBackToCanvas, getParameterInputByName } from './ndv';
|
||||
import { ROUTES } from '../constants';
|
||||
import type { OpenContextMenuOptions } from '../types';
|
||||
|
||||
/**
|
||||
* Types
|
||||
|
@ -24,7 +25,36 @@ export type EndpointType =
|
|||
* Getters
|
||||
*/
|
||||
|
||||
export function getAddInputEndpointByType(nodeName: string, endpointType: EndpointType) {
|
||||
export function getCanvas() {
|
||||
return cy.getByTestId('canvas');
|
||||
}
|
||||
|
||||
export function getCanvasPane() {
|
||||
return cy.ifCanvasVersion(
|
||||
() => cy.getByTestId('node-view-background'),
|
||||
() => getCanvas().find('.vue-flow__pane'),
|
||||
);
|
||||
}
|
||||
|
||||
export function getContextMenu() {
|
||||
return cy.getByTestId('context-menu').find('.el-dropdown-menu');
|
||||
}
|
||||
|
||||
export function getContextMenuAction(action: string) {
|
||||
return cy.getByTestId(`context-menu-item-${action}`);
|
||||
}
|
||||
|
||||
export function getInputPlusHandle(nodeName: string) {
|
||||
return cy.ifCanvasVersion(
|
||||
() => cy.get(`.add-input-endpoint[data-endpoint-name="${nodeName}"]`),
|
||||
() =>
|
||||
cy.get(
|
||||
`[data-test-id="canvas-node-input-handle"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"]`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getInputPlusHandleByType(nodeName: string, endpointType: EndpointType) {
|
||||
return cy.ifCanvasVersion(
|
||||
() =>
|
||||
cy.get(
|
||||
|
@ -37,6 +67,36 @@ export function getAddInputEndpointByType(nodeName: string, endpointType: Endpoi
|
|||
);
|
||||
}
|
||||
|
||||
export function getOutputHandle(nodeName: string) {
|
||||
return cy.ifCanvasVersion(
|
||||
() => cy.get(`.add-output-endpoint[data-endpoint-name="${nodeName}"]`),
|
||||
() => cy.get(`[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"]`),
|
||||
);
|
||||
}
|
||||
|
||||
export function getOutputPlusHandle(nodeName: string) {
|
||||
return cy.ifCanvasVersion(
|
||||
() => cy.get(`.add-output-endpoint[data-endpoint-name="${nodeName}"]`),
|
||||
() =>
|
||||
cy.get(
|
||||
`[data-test-id="canvas-node-output-handle"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"]`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getOutputPlusHandleByType(nodeName: string, endpointType: EndpointType) {
|
||||
return cy.ifCanvasVersion(
|
||||
() =>
|
||||
cy.get(
|
||||
`.add-output-endpoint[data-jtk-scope-${endpointType}][data-endpoint-name="${nodeName}"]`,
|
||||
),
|
||||
() =>
|
||||
cy.get(
|
||||
`[data-test-id="canvas-node-output-handle"][data-connection-type="${endpointType}"][data-node-name="${nodeName}"] [data-test-id="canvas-handle-plus"]`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getNodeCreatorItems() {
|
||||
return cy.getByTestId('item-iterator-item');
|
||||
}
|
||||
|
@ -60,6 +120,13 @@ export function getNodeByName(name: string) {
|
|||
);
|
||||
}
|
||||
|
||||
export function getNodeRenderedTypeByName(name: string) {
|
||||
return cy.ifCanvasVersion(
|
||||
() => getNodeByName(name),
|
||||
() => getNodeByName(name).find('[data-canvas-node-render-type]'),
|
||||
);
|
||||
}
|
||||
|
||||
export function getWorkflowHistoryCloseButton() {
|
||||
return cy.getByTestId('workflow-history-close-button');
|
||||
}
|
||||
|
@ -85,6 +152,12 @@ export function getConnectionBySourceAndTarget(source: string, target: string) {
|
|||
);
|
||||
}
|
||||
|
||||
export function getConnectionLabelBySourceAndTarget(source: string, target: string) {
|
||||
return cy
|
||||
.getByTestId('edge-label')
|
||||
.filter(`[data-source-node-name="${source}"][data-target-node-name="${target}"]`);
|
||||
}
|
||||
|
||||
export function getNodeCreatorSearchBar() {
|
||||
return cy.getByTestId('node-creator-search-bar');
|
||||
}
|
||||
|
@ -94,10 +167,7 @@ export function getNodeCreatorPlusButton() {
|
|||
}
|
||||
|
||||
export function getCanvasNodes() {
|
||||
return cy.ifCanvasVersion(
|
||||
() => cy.getByTestId('canvas-node'),
|
||||
() => cy.getByTestId('canvas-node').not('[data-node-type="n8n-nodes-internal.addNodes"]'),
|
||||
);
|
||||
return cy.getByTestId('canvas-node');
|
||||
}
|
||||
|
||||
export function getCanvasNodeByName(nodeName: string) {
|
||||
|
@ -157,7 +227,7 @@ function connectNodeToParent(
|
|||
parentNodeName: string,
|
||||
exactMatch = false,
|
||||
) {
|
||||
getAddInputEndpointByType(parentNodeName, endpointType).click({ force: true });
|
||||
getInputPlusHandleByType(parentNodeName, endpointType).click({ force: true });
|
||||
if (exactMatch) {
|
||||
getNodeCreatorItems()
|
||||
.contains(new RegExp('^' + nodeName + '$', 'g'))
|
||||
|
@ -257,3 +327,34 @@ export function deleteNode(name: string) {
|
|||
getCanvasNodeByName(name).first().click();
|
||||
cy.get('body').type('{del}');
|
||||
}
|
||||
|
||||
export function openContextMenu(
|
||||
nodeName?: string,
|
||||
{ method = 'right-click', anchor = 'center' }: OpenContextMenuOptions = {},
|
||||
) {
|
||||
let target;
|
||||
if (nodeName) {
|
||||
target =
|
||||
method === 'right-click' ? getNodeRenderedTypeByName(nodeName) : getNodeByName(nodeName);
|
||||
} else {
|
||||
target = getCanvasPane();
|
||||
}
|
||||
|
||||
if (method === 'right-click') {
|
||||
target.rightclick(nodeName ? anchor : 'topLeft', { force: true });
|
||||
} else {
|
||||
target.realHover();
|
||||
target.find('[data-test-id="overflow-node-button"]').click({ force: true });
|
||||
}
|
||||
|
||||
cy.ifCanvasVersion(
|
||||
() => {},
|
||||
() => {
|
||||
getContextMenu().should('be.visible');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function clickContextMenuAction(action: string) {
|
||||
getContextMenuAction(action).click();
|
||||
}
|
||||
|
|
|
@ -1,16 +1,15 @@
|
|||
import { getCanvasNodes } from '../composables/workflow';
|
||||
import {
|
||||
SCHEDULE_TRIGGER_NODE_NAME,
|
||||
CODE_NODE_NAME,
|
||||
SET_NODE_NAME,
|
||||
EDIT_FIELDS_SET_NODE_NAME,
|
||||
MANUAL_TRIGGER_NODE_NAME,
|
||||
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
|
||||
} from '../constants';
|
||||
import { MessageBox as MessageBoxClass } from '../pages/modals/message-box';
|
||||
import { NDV } from '../pages/ndv';
|
||||
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
|
||||
|
||||
// Suite-specific constants
|
||||
const CODE_NODE_NEW_NAME = 'Something else';
|
||||
|
||||
const WorkflowPage = new WorkflowPageClass();
|
||||
const messageBox = new MessageBoxClass();
|
||||
const ndv = new NDV();
|
||||
|
@ -20,40 +19,6 @@ describe('Undo/Redo', () => {
|
|||
WorkflowPage.actions.visit();
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Fix redo connections
|
||||
it('should undo/redo adding node in the middle', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
WorkflowPage.actions.addNodeBetweenNodes(
|
||||
SCHEDULE_TRIGGER_NODE_NAME,
|
||||
CODE_NODE_NAME,
|
||||
SET_NODE_NAME,
|
||||
);
|
||||
WorkflowPage.actions.zoomToFit();
|
||||
WorkflowPage.getters.canvasNodeByName('Code').then(($codeNode) => {
|
||||
const cssLeft = parseInt($codeNode.css('left'));
|
||||
const cssTop = parseInt($codeNode.css('top'));
|
||||
|
||||
WorkflowPage.actions.hitUndo();
|
||||
WorkflowPage.getters.canvasNodes().should('have.have.length', 2);
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 1);
|
||||
WorkflowPage.actions.hitUndo();
|
||||
WorkflowPage.getters.canvasNodes().should('have.have.length', 1);
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||
WorkflowPage.actions.hitRedo();
|
||||
WorkflowPage.getters.canvasNodes().should('have.have.length', 2);
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 1);
|
||||
WorkflowPage.actions.hitRedo();
|
||||
WorkflowPage.getters.canvasNodes().should('have.have.length', 3);
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 2);
|
||||
// Last node should be added back to original position
|
||||
WorkflowPage.getters
|
||||
.canvasNodeByName('Code')
|
||||
.should('have.css', 'left', cssLeft + 'px')
|
||||
.should('have.css', 'top', cssTop + 'px');
|
||||
});
|
||||
});
|
||||
|
||||
it('should undo/redo deleting node using context menu', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
|
@ -115,34 +80,60 @@ describe('Undo/Redo', () => {
|
|||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Fix moving of nodes via e2e tests
|
||||
it('should undo/redo moving nodes', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).then(($node) => {
|
||||
const initialPosition = $node.position();
|
||||
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], { clickToFinish: true });
|
||||
|
||||
WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).then(($node) => {
|
||||
const cssLeft = parseInt($node.css('left'));
|
||||
const cssTop = parseInt($node.css('top'));
|
||||
expect(cssLeft).to.be.greaterThan(initialPosition.left);
|
||||
expect(cssTop).to.be.greaterThan(initialPosition.top);
|
||||
});
|
||||
WorkflowPage.actions.zoomToFit();
|
||||
|
||||
WorkflowPage.actions.hitUndo();
|
||||
WorkflowPage.getters
|
||||
.canvasNodeByName(CODE_NODE_NAME)
|
||||
.should('have.css', 'left', `${initialPosition.left}px`)
|
||||
.should('have.css', 'top', `${initialPosition.top}px`);
|
||||
WorkflowPage.actions.hitRedo();
|
||||
WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).then(($node) => {
|
||||
const cssLeft = parseInt($node.css('left'));
|
||||
const cssTop = parseInt($node.css('top'));
|
||||
expect(cssLeft).to.be.greaterThan(initialPosition.left);
|
||||
expect(cssTop).to.be.greaterThan(initialPosition.top);
|
||||
getCanvasNodes()
|
||||
.last()
|
||||
.then(($node) => {
|
||||
const { x: x1, y: y1 } = $node[0].getBoundingClientRect();
|
||||
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], {
|
||||
clickToFinish: true,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
cy.drag(getCanvasNodes().last(), [50, 150], {
|
||||
realMouse: true,
|
||||
abs: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
getCanvasNodes()
|
||||
.last()
|
||||
.then(($node) => {
|
||||
const { x: x2, y: y2 } = $node[0].getBoundingClientRect();
|
||||
expect(x2).to.be.greaterThan(x1);
|
||||
expect(y2).to.be.greaterThan(y1);
|
||||
});
|
||||
|
||||
WorkflowPage.actions.hitUndo();
|
||||
|
||||
getCanvasNodes()
|
||||
.last()
|
||||
.then(($node) => {
|
||||
const { x: x3, y: y3 } = $node[0].getBoundingClientRect();
|
||||
expect(x3).to.equal(x1);
|
||||
expect(y3).to.equal(y1);
|
||||
});
|
||||
|
||||
WorkflowPage.actions.hitRedo();
|
||||
|
||||
getCanvasNodes()
|
||||
.last()
|
||||
.then(($node) => {
|
||||
const { x: x4, y: y4 } = $node[0].getBoundingClientRect();
|
||||
expect(x4).to.be.greaterThan(x1);
|
||||
expect(y4).to.be.greaterThan(y1);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should undo/redo deleting a connection using context menu', () => {
|
||||
|
@ -155,17 +146,6 @@ describe('Undo/Redo', () => {
|
|||
WorkflowPage.actions.hitRedo();
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||
});
|
||||
// FIXME: Canvas V2: Fix disconnecting by moving
|
||||
it('should undo/redo deleting a connection by moving it away', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
cy.drag('.rect-input-endpoint.jtk-endpoint-connected', [0, -100]);
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||
WorkflowPage.actions.hitUndo();
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 1);
|
||||
WorkflowPage.actions.hitRedo();
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||
});
|
||||
|
||||
it('should undo/redo disabling a node using context menu', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
|
@ -204,23 +184,6 @@ describe('Undo/Redo', () => {
|
|||
WorkflowPage.getters.disabledNodes().should('have.length', 2);
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Fix undo renaming node
|
||||
it('should undo/redo renaming node using keyboard shortcut', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
WorkflowPage.getters.canvasNodes().last().click();
|
||||
cy.get('body').trigger('keydown', { key: 'F2' });
|
||||
cy.get('.rename-prompt').should('be.visible');
|
||||
cy.get('body').type(CODE_NODE_NEW_NAME);
|
||||
cy.get('body').type('{enter}');
|
||||
WorkflowPage.actions.hitUndo();
|
||||
cy.get('body').type('{esc}');
|
||||
WorkflowPage.getters.canvasNodeByName(CODE_NODE_NAME).should('exist');
|
||||
WorkflowPage.actions.hitRedo();
|
||||
cy.get('body').type('{esc}');
|
||||
WorkflowPage.getters.canvasNodeByName(CODE_NODE_NEW_NAME).should('exist');
|
||||
});
|
||||
|
||||
it('should undo/redo duplicating a node', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
|
@ -243,77 +206,6 @@ describe('Undo/Redo', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Figure out why moving doesn't work from e2e
|
||||
it('should undo/redo multiple steps', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
// WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME);
|
||||
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
WorkflowPage.actions.zoomToFit();
|
||||
|
||||
// Disable last node
|
||||
WorkflowPage.getters.canvasNodes().last().click();
|
||||
WorkflowPage.actions.hitDisableNodeShortcut();
|
||||
|
||||
// Move first one
|
||||
WorkflowPage.actions
|
||||
.getNodePosition(WorkflowPage.getters.canvasNodes().first())
|
||||
.then((initialPosition) => {
|
||||
WorkflowPage.getters.canvasNodes().first().click();
|
||||
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], {
|
||||
clickToFinish: true,
|
||||
});
|
||||
WorkflowPage.getters
|
||||
.canvasNodes()
|
||||
.first()
|
||||
.then(($node) => {
|
||||
const cssLeft = parseInt($node.css('left'));
|
||||
const cssTop = parseInt($node.css('top'));
|
||||
expect(cssLeft).to.be.greaterThan(initialPosition.left);
|
||||
expect(cssTop).to.be.greaterThan(initialPosition.top);
|
||||
});
|
||||
|
||||
// Delete the set node
|
||||
WorkflowPage.getters.canvasNodeByName(EDIT_FIELDS_SET_NODE_NAME).click().click();
|
||||
cy.get('body').type('{backspace}');
|
||||
|
||||
// First undo: Should return deleted node
|
||||
WorkflowPage.actions.hitUndo();
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 4);
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 3);
|
||||
// Second undo: Should move first node to it's original position
|
||||
WorkflowPage.actions.hitUndo();
|
||||
WorkflowPage.getters
|
||||
.canvasNodes()
|
||||
.first()
|
||||
.should('have.css', 'left', `${initialPosition.left}px`)
|
||||
.should('have.css', 'top', `${initialPosition.top}px`);
|
||||
// Third undo: Should enable last node
|
||||
WorkflowPage.actions.hitUndo();
|
||||
WorkflowPage.getters.disabledNodes().should('have.length', 0);
|
||||
|
||||
// First redo: Should disable last node
|
||||
WorkflowPage.actions.hitRedo();
|
||||
WorkflowPage.getters.disabledNodes().should('have.length', 1);
|
||||
// Second redo: Should move the first node
|
||||
WorkflowPage.actions.hitRedo();
|
||||
WorkflowPage.getters
|
||||
.canvasNodes()
|
||||
.first()
|
||||
.then(($node) => {
|
||||
const cssLeft = parseInt($node.css('left'));
|
||||
const cssTop = parseInt($node.css('top'));
|
||||
expect(cssLeft).to.be.greaterThan(initialPosition.left);
|
||||
expect(cssTop).to.be.greaterThan(initialPosition.top);
|
||||
});
|
||||
// Third redo: Should delete the Set node
|
||||
WorkflowPage.actions.hitRedo();
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 3);
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 2);
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to copy and paste pinned data nodes in workflows with dynamic Switch node', () => {
|
||||
cy.fixture('Test_workflow_form_switch.json').then((data) => {
|
||||
cy.get('body').paste(JSON.stringify(data));
|
||||
|
|
|
@ -129,7 +129,7 @@ describe('Inline expression editor', () => {
|
|||
|
||||
// Run workflow
|
||||
ndv.actions.close();
|
||||
WorkflowPage.actions.executeNode('No Operation', { anchor: 'topLeft' });
|
||||
WorkflowPage.actions.executeNode('No Operation, do nothing', { anchor: 'topLeft' });
|
||||
WorkflowPage.actions.openNode('Hacker News');
|
||||
WorkflowPage.actions.openInlineExpressionEditor();
|
||||
|
||||
|
|
|
@ -4,9 +4,9 @@ import {
|
|||
CODE_NODE_NAME,
|
||||
SCHEDULE_TRIGGER_NODE_NAME,
|
||||
EDIT_FIELDS_SET_NODE_NAME,
|
||||
IF_NODE_NAME,
|
||||
HTTP_REQUEST_NODE_NAME,
|
||||
} from './../constants';
|
||||
import { getCanvasPane } from '../composables/workflow';
|
||||
import { successToast } from '../pages/notifications';
|
||||
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
|
||||
|
||||
|
@ -16,64 +16,12 @@ describe('Canvas Actions', () => {
|
|||
WorkflowPage.actions.visit();
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Missing execute button if no nodes
|
||||
it('should render canvas', () => {
|
||||
WorkflowPage.getters.nodeViewRoot().should('be.visible');
|
||||
WorkflowPage.getters.canvasPlusButton().should('be.visible');
|
||||
WorkflowPage.getters.zoomToFitButton().should('be.visible');
|
||||
WorkflowPage.getters.zoomInButton().should('be.visible');
|
||||
WorkflowPage.getters.zoomOutButton().should('be.visible');
|
||||
WorkflowPage.getters.executeWorkflowButton().should('be.visible');
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Fix changing of connection
|
||||
it('should connect and disconnect a simple node', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
|
||||
WorkflowPage.getters.nodeViewBackground().click(600, 200, { force: true });
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 1);
|
||||
|
||||
WorkflowPage.getters.nodeViewBackground().click(600, 400, { force: true });
|
||||
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
|
||||
|
||||
// Change connection from Set to Set1
|
||||
cy.draganddrop(
|
||||
WorkflowPage.getters.getEndpointSelector('input', EDIT_FIELDS_SET_NODE_NAME),
|
||||
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
|
||||
);
|
||||
|
||||
WorkflowPage.getters
|
||||
.getConnectionBetweenNodes(MANUAL_TRIGGER_NODE_DISPLAY_NAME, `${EDIT_FIELDS_SET_NODE_NAME}1`)
|
||||
.should('be.visible');
|
||||
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 1);
|
||||
// Disconnect Set1
|
||||
cy.drag(
|
||||
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
|
||||
[-200, 100],
|
||||
);
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||
});
|
||||
|
||||
it('should add first step', () => {
|
||||
WorkflowPage.getters.canvasPlusButton().should('be.visible');
|
||||
WorkflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 1);
|
||||
});
|
||||
|
||||
it('should add a node via plus endpoint drag', () => {
|
||||
WorkflowPage.getters.canvasPlusButton().should('be.visible');
|
||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME, true);
|
||||
|
||||
cy.drag(
|
||||
WorkflowPage.getters.getEndpointSelector('plus', SCHEDULE_TRIGGER_NODE_NAME),
|
||||
[100, 100],
|
||||
);
|
||||
|
||||
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
|
||||
WorkflowPage.actions.addNodeToCanvas(IF_NODE_NAME, false);
|
||||
WorkflowPage.getters.nodeViewBackground().click({ force: true });
|
||||
});
|
||||
|
||||
it('should add a connected node using plus endpoint', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.getters.canvasNodePlusEndpointByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||
|
@ -116,7 +64,7 @@ describe('Canvas Actions', () => {
|
|||
it('should add disconnected node if nothing is selected', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
// Deselect nodes
|
||||
WorkflowPage.getters.nodeView().click({ force: true });
|
||||
getCanvasPane().click({ force: true });
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 2);
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||
|
@ -166,15 +114,6 @@ describe('Canvas Actions', () => {
|
|||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Fix disconnecting of connection by dragging it
|
||||
it('should delete a connection by moving it away from endpoint', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
cy.drag(WorkflowPage.getters.getEndpointSelector('input', CODE_NODE_NAME), [0, -100]);
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 0);
|
||||
});
|
||||
|
||||
describe('Node hover actions', () => {
|
||||
it('should execute node', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
|
@ -184,7 +123,11 @@ describe('Canvas Actions', () => {
|
|||
.last()
|
||||
.findChildByTestId('execute-node-button')
|
||||
.click({ force: true });
|
||||
|
||||
successToast().should('have.length', 1);
|
||||
|
||||
WorkflowPage.actions.executeNode(CODE_NODE_NAME);
|
||||
|
||||
successToast().should('have.length', 2);
|
||||
successToast().should('contain.text', 'Node executed successfully');
|
||||
});
|
||||
|
@ -235,7 +178,6 @@ describe('Canvas Actions', () => {
|
|||
WorkflowPage.getters.selectedNodes().should('have.length', 0);
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Selection via arrow keys is broken
|
||||
it('should select nodes using arrow keys', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||
|
@ -259,7 +201,6 @@ describe('Canvas Actions', () => {
|
|||
);
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Selection via shift and arrow keys is broken
|
||||
it('should select nodes using shift and arrow keys', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||
|
@ -268,31 +209,4 @@ describe('Canvas Actions', () => {
|
|||
cy.get('body').type('{shift}', { release: false }).type('{leftArrow}');
|
||||
WorkflowPage.getters.selectedNodes().should('have.length', 2);
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Fix select & deselect
|
||||
it('should not break lasso selection when dragging node action buttons', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.getters
|
||||
.canvasNodes()
|
||||
.last()
|
||||
.findChildByTestId('execute-node-button')
|
||||
.as('executeNodeButton');
|
||||
cy.drag('@executeNodeButton', [200, 200]);
|
||||
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Fix select & deselect
|
||||
it('should not break lasso selection with multiple clicks on node action buttons', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
|
||||
WorkflowPage.getters.canvasNodes().last().as('lastNode');
|
||||
cy.get('@lastNode').findChildByTestId('execute-node-button').as('executeNodeButton');
|
||||
for (let i = 0; i < 20; i++) {
|
||||
cy.get('@lastNode').realHover();
|
||||
cy.get('@executeNodeButton').should('be.visible');
|
||||
cy.get('@executeNodeButton').realTouch();
|
||||
cy.getByTestId('execute-workflow-button').realHover();
|
||||
WorkflowPage.actions.testLassoSelection([100, 100], [200, 200]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,9 +7,17 @@ import {
|
|||
SWITCH_NODE_NAME,
|
||||
MERGE_NODE_NAME,
|
||||
} from './../constants';
|
||||
import {
|
||||
clickContextMenuAction,
|
||||
getCanvasNodeByName,
|
||||
getCanvasNodes,
|
||||
getConnectionBySourceAndTarget,
|
||||
getConnectionLabelBySourceAndTarget,
|
||||
getOutputPlusHandle,
|
||||
openContextMenu,
|
||||
} from '../composables/workflow';
|
||||
import { NDV, WorkflowExecutionsTab } from '../pages';
|
||||
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
|
||||
import { isCanvasV2 } from '../utils/workflowUtils';
|
||||
|
||||
const WorkflowPage = new WorkflowPageClass();
|
||||
const ExecutionsTab = new WorkflowExecutionsTab();
|
||||
|
@ -20,8 +28,6 @@ const ZOOM_IN_X2_FACTOR = 1.5625; // Zoom in factor after two clicks
|
|||
const ZOOM_OUT_X1_FACTOR = 0.8;
|
||||
const ZOOM_OUT_X2_FACTOR = 0.64;
|
||||
|
||||
const PINCH_ZOOM_IN_FACTOR = 1.05702;
|
||||
const PINCH_ZOOM_OUT_FACTOR = 0.946058;
|
||||
const RENAME_NODE_NAME = 'Something else';
|
||||
const RENAME_NODE_NAME2 = 'Something different';
|
||||
|
||||
|
@ -41,27 +47,52 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
|
||||
NDVDialog.actions.close();
|
||||
for (let i = 0; i < desiredOutputs; i++) {
|
||||
WorkflowPage.getters.canvasNodePlusEndpointByName(SWITCH_NODE_NAME, i).click({ force: true });
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
WorkflowPage.getters
|
||||
.canvasNodePlusEndpointByName(SWITCH_NODE_NAME, i)
|
||||
.click({ force: true });
|
||||
},
|
||||
() => {
|
||||
getOutputPlusHandle(SWITCH_NODE_NAME).eq(0).click();
|
||||
},
|
||||
);
|
||||
WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible');
|
||||
WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, false);
|
||||
WorkflowPage.actions.zoomToFit();
|
||||
}
|
||||
WorkflowPage.getters.nodeViewBackground().click({ force: true });
|
||||
WorkflowPage.getters.canvasNodePlusEndpointByName(`${EDIT_FIELDS_SET_NODE_NAME}3`).click();
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
WorkflowPage.getters.canvasNodePlusEndpointByName(`${EDIT_FIELDS_SET_NODE_NAME}3`).click();
|
||||
},
|
||||
() => {
|
||||
getOutputPlusHandle(`${EDIT_FIELDS_SET_NODE_NAME}3`).click();
|
||||
},
|
||||
);
|
||||
WorkflowPage.actions.addNodeToCanvas(SWITCH_NODE_NAME, false);
|
||||
WorkflowPage.actions.saveWorkflowOnButtonClick();
|
||||
cy.reload();
|
||||
cy.waitForLoad();
|
||||
// Make sure outputless switch was connected correctly
|
||||
WorkflowPage.getters
|
||||
.getConnectionBetweenNodes(`${EDIT_FIELDS_SET_NODE_NAME}3`, `${SWITCH_NODE_NAME}1`)
|
||||
.should('exist');
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
WorkflowPage.getters
|
||||
.getConnectionBetweenNodes(`${EDIT_FIELDS_SET_NODE_NAME}3`, `${SWITCH_NODE_NAME}1`)
|
||||
.should('exist');
|
||||
},
|
||||
() => {
|
||||
getConnectionBySourceAndTarget(
|
||||
`${EDIT_FIELDS_SET_NODE_NAME}3`,
|
||||
`${SWITCH_NODE_NAME}1`,
|
||||
).should('exist');
|
||||
},
|
||||
);
|
||||
// Make sure all connections are there after reload
|
||||
for (let i = 0; i < desiredOutputs; i++) {
|
||||
const setName = `${EDIT_FIELDS_SET_NODE_NAME}${i > 0 ? i : ''}`;
|
||||
WorkflowPage.getters
|
||||
.getConnectionBetweenNodes(`${SWITCH_NODE_NAME}`, setName)
|
||||
.should('exist');
|
||||
|
||||
getConnectionBySourceAndTarget(`${SWITCH_NODE_NAME}`, setName).should('exist');
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -84,14 +115,29 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
);
|
||||
|
||||
// Connect Set1 and Set2 to merge
|
||||
cy.draganddrop(
|
||||
WorkflowPage.getters.getEndpointSelector('plus', EDIT_FIELDS_SET_NODE_NAME),
|
||||
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 0),
|
||||
);
|
||||
cy.draganddrop(
|
||||
WorkflowPage.getters.getEndpointSelector('plus', `${EDIT_FIELDS_SET_NODE_NAME}1`),
|
||||
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 1),
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
cy.draganddrop(
|
||||
WorkflowPage.getters.getEndpointSelector('plus', EDIT_FIELDS_SET_NODE_NAME),
|
||||
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 0),
|
||||
);
|
||||
cy.draganddrop(
|
||||
WorkflowPage.getters.getEndpointSelector('plus', `${EDIT_FIELDS_SET_NODE_NAME}1`),
|
||||
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 1),
|
||||
);
|
||||
},
|
||||
() => {
|
||||
cy.draganddrop(
|
||||
WorkflowPage.getters.getEndpointSelector('output', EDIT_FIELDS_SET_NODE_NAME),
|
||||
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 0),
|
||||
);
|
||||
cy.draganddrop(
|
||||
WorkflowPage.getters.getEndpointSelector('output', `${EDIT_FIELDS_SET_NODE_NAME}1`),
|
||||
WorkflowPage.getters.getEndpointSelector('input', MERGE_NODE_NAME, 1),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const checkConnections = () => {
|
||||
WorkflowPage.getters
|
||||
.getConnectionBetweenNodes(
|
||||
|
@ -117,10 +163,22 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
WorkflowPage.actions.executeWorkflow();
|
||||
WorkflowPage.getters.stopExecutionButton().should('not.exist');
|
||||
|
||||
// Make sure all connections are there after save & reload
|
||||
WorkflowPage.actions.saveWorkflowOnButtonClick();
|
||||
cy.reload();
|
||||
cy.waitForLoad();
|
||||
checkConnections();
|
||||
|
||||
WorkflowPage.actions.executeWorkflow();
|
||||
WorkflowPage.getters.stopExecutionButton().should('not.exist');
|
||||
|
||||
// If the merged set nodes are connected and executed correctly, there should be 2 items in the output of merge node
|
||||
cy.ifCanvasVersion(
|
||||
() => cy.get('[data-label="2 items"]').should('be.visible'),
|
||||
() => cy.getByTestId('canvas-node-output-handle').contains('2 items').should('be.visible'),
|
||||
() =>
|
||||
getConnectionLabelBySourceAndTarget(`${EDIT_FIELDS_SET_NODE_NAME}1`, MERGE_NODE_NAME)
|
||||
.contains('2 items')
|
||||
.should('be.visible'),
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -144,7 +202,10 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
|
||||
cy.ifCanvasVersion(
|
||||
() => cy.get('.plus-draggable-endpoint').should('have.class', 'ep-success'),
|
||||
() => cy.getByTestId('canvas-handle-plus').should('have.attr', 'data-plus-type', 'success'),
|
||||
() =>
|
||||
cy
|
||||
.getByTestId('canvas-handle-plus-wrapper')
|
||||
.should('have.attr', 'data-plus-type', 'success'),
|
||||
);
|
||||
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
|
@ -212,8 +273,8 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
cy.wait(500);
|
||||
WorkflowPage.actions.selectAllFromContextMenu();
|
||||
WorkflowPage.actions.openContextMenu();
|
||||
WorkflowPage.actions.contextMenuAction('delete');
|
||||
openContextMenu();
|
||||
clickContextMenuAction('delete');
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 0);
|
||||
});
|
||||
|
||||
|
@ -228,41 +289,43 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
cy.wait(500);
|
||||
WorkflowPage.actions.selectAllFromContextMenu();
|
||||
WorkflowPage.actions.openContextMenu();
|
||||
WorkflowPage.actions.contextMenuAction('delete');
|
||||
openContextMenu();
|
||||
clickContextMenuAction('delete');
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 0);
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Figure out how to test moving of the node
|
||||
it('should move node', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||
WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME);
|
||||
|
||||
WorkflowPage.actions.zoomToFit();
|
||||
WorkflowPage.getters
|
||||
.canvasNodes()
|
||||
|
||||
getCanvasNodes()
|
||||
.last()
|
||||
.then(($node) => {
|
||||
const { left, top } = $node.position();
|
||||
const { x: x1, y: y1 } = $node[0].getBoundingClientRect();
|
||||
|
||||
if (isCanvasV2()) {
|
||||
cy.drag('.vue-flow__node', [300, 300], {
|
||||
realMouse: true,
|
||||
});
|
||||
} else {
|
||||
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], {
|
||||
clickToFinish: true,
|
||||
});
|
||||
}
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 150], {
|
||||
clickToFinish: true,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
cy.drag(getCanvasNodes().last(), [50, 150], {
|
||||
realMouse: true,
|
||||
abs: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
WorkflowPage.getters
|
||||
.canvasNodes()
|
||||
getCanvasNodes()
|
||||
.last()
|
||||
.then(($node) => {
|
||||
const { left: newLeft, top: newTop } = $node.position();
|
||||
expect(newLeft).to.be.greaterThan(left);
|
||||
expect(newTop).to.be.greaterThan(top);
|
||||
const { x: x2, y: y2 } = $node[0].getBoundingClientRect();
|
||||
expect(x2).to.be.greaterThan(x1);
|
||||
expect(y2).to.be.greaterThan(y1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -304,26 +367,6 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
zoomAndCheck('zoomOut', ZOOM_OUT_X2_FACTOR);
|
||||
});
|
||||
|
||||
it('should zoom using scroll or pinch gesture', () => {
|
||||
WorkflowPage.actions.pinchToZoom(1, 'zoomIn');
|
||||
|
||||
// V2 Canvas is using the same zoom factor for both pinch and scroll
|
||||
cy.ifCanvasVersion(
|
||||
() => checkZoomLevel(PINCH_ZOOM_IN_FACTOR),
|
||||
() => checkZoomLevel(ZOOM_IN_X1_FACTOR),
|
||||
);
|
||||
|
||||
WorkflowPage.actions.pinchToZoom(1, 'zoomOut');
|
||||
checkZoomLevel(1); // Zoom in 1x + Zoom out 1x should reset to default (=1)
|
||||
|
||||
WorkflowPage.actions.pinchToZoom(1, 'zoomOut');
|
||||
|
||||
cy.ifCanvasVersion(
|
||||
() => checkZoomLevel(PINCH_ZOOM_OUT_FACTOR),
|
||||
() => checkZoomLevel(ZOOM_OUT_X1_FACTOR),
|
||||
);
|
||||
});
|
||||
|
||||
it('should reset zoom', () => {
|
||||
WorkflowPage.getters.resetZoomButton().should('not.exist');
|
||||
WorkflowPage.getters.zoomInButton().click();
|
||||
|
@ -369,7 +412,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
WorkflowPage.actions.hitDisableNodeShortcut();
|
||||
WorkflowPage.getters.disabledNodes().should('have.length', 0);
|
||||
WorkflowPage.actions.deselectAll();
|
||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||
getCanvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||
WorkflowPage.actions.hitDisableNodeShortcut();
|
||||
WorkflowPage.getters.disabledNodes().should('have.length', 1);
|
||||
WorkflowPage.actions.hitSelectAll();
|
||||
|
@ -378,19 +421,19 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
|
||||
// Context menu
|
||||
WorkflowPage.actions.hitSelectAll();
|
||||
WorkflowPage.actions.openContextMenu();
|
||||
openContextMenu();
|
||||
WorkflowPage.actions.contextMenuAction('toggle_activation');
|
||||
WorkflowPage.getters.disabledNodes().should('have.length', 0);
|
||||
WorkflowPage.actions.openContextMenu();
|
||||
openContextMenu();
|
||||
WorkflowPage.actions.contextMenuAction('toggle_activation');
|
||||
WorkflowPage.getters.disabledNodes().should('have.length', 2);
|
||||
WorkflowPage.actions.deselectAll();
|
||||
WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click();
|
||||
WorkflowPage.actions.openContextMenu();
|
||||
openContextMenu();
|
||||
WorkflowPage.actions.contextMenuAction('toggle_activation');
|
||||
WorkflowPage.getters.disabledNodes().should('have.length', 1);
|
||||
WorkflowPage.actions.hitSelectAll();
|
||||
WorkflowPage.actions.openContextMenu();
|
||||
openContextMenu();
|
||||
WorkflowPage.actions.contextMenuAction('toggle_activation');
|
||||
WorkflowPage.getters.disabledNodes().should('have.length', 2);
|
||||
});
|
||||
|
@ -466,7 +509,7 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
WorkflowPage.getters.canvasNodes().should('have.length', 2);
|
||||
WorkflowPage.getters.nodeConnections().should('have.length', 1);
|
||||
});
|
||||
// FIXME: Canvas V2: Credentials should show issue on the first open
|
||||
|
||||
it('should remove unknown credentials on pasting workflow', () => {
|
||||
cy.fixture('workflow-with-unknown-credentials.json').then((data) => {
|
||||
cy.get('body').paste(JSON.stringify(data));
|
||||
|
@ -478,35 +521,4 @@ describe('Canvas Node Manipulation and Navigation', () => {
|
|||
NDVDialog.actions.close();
|
||||
});
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Unknown nodes should still render connection endpoints
|
||||
it('should render connections correctly if unkown nodes are present', () => {
|
||||
const unknownNodeName = 'Unknown node';
|
||||
cy.createFixtureWorkflow('workflow-with-unknown-nodes.json', 'Unknown nodes');
|
||||
|
||||
WorkflowPage.getters.canvasNodeByName(`${unknownNodeName} 1`).should('exist');
|
||||
WorkflowPage.getters.canvasNodeByName(`${unknownNodeName} 2`).should('exist');
|
||||
WorkflowPage.actions.zoomToFit();
|
||||
|
||||
cy.draganddrop(
|
||||
WorkflowPage.getters.getEndpointSelector('plus', `${unknownNodeName} 1`),
|
||||
WorkflowPage.getters.getEndpointSelector('input', EDIT_FIELDS_SET_NODE_NAME),
|
||||
);
|
||||
|
||||
cy.draganddrop(
|
||||
WorkflowPage.getters.getEndpointSelector('plus', `${unknownNodeName} 2`),
|
||||
WorkflowPage.getters.getEndpointSelector('input', `${EDIT_FIELDS_SET_NODE_NAME}1`),
|
||||
);
|
||||
|
||||
WorkflowPage.actions.executeWorkflow();
|
||||
cy.contains('Unrecognized node type').should('be.visible');
|
||||
|
||||
WorkflowPage.actions.deselectAll();
|
||||
WorkflowPage.actions.deleteNodeFromContextMenu(`${unknownNodeName} 1`);
|
||||
WorkflowPage.actions.deleteNodeFromContextMenu(`${unknownNodeName} 2`);
|
||||
|
||||
WorkflowPage.actions.executeWorkflow();
|
||||
|
||||
cy.contains('Unrecognized node type').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,3 @@
|
|||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { simpleWebhookCall, waitForWebhook } from './16-webhook-node.cy';
|
||||
import {
|
||||
HTTP_REQUEST_NODE_NAME,
|
||||
MANUAL_TRIGGER_NODE_NAME,
|
||||
|
@ -109,36 +106,6 @@ describe('Data pinning', () => {
|
|||
ndv.getters.outputTbodyCell(1, 0).should('include.text', 1);
|
||||
});
|
||||
|
||||
it('Should be able to pin data from canvas (context menu or shortcut)', () => {
|
||||
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger');
|
||||
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME);
|
||||
workflowPage.actions.openContextMenu(EDIT_FIELDS_SET_NODE_NAME, { method: 'overflow-button' });
|
||||
workflowPage.getters
|
||||
.contextMenuAction('toggle_pin')
|
||||
.parent()
|
||||
.should('have.class', 'is-disabled');
|
||||
|
||||
cy.get('body').type('{esc}');
|
||||
|
||||
// Unpin using context menu
|
||||
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
|
||||
ndv.actions.setPinnedData([{ test: 1 }]);
|
||||
ndv.actions.close();
|
||||
workflowPage.actions.pinNode(EDIT_FIELDS_SET_NODE_NAME);
|
||||
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
|
||||
ndv.getters.nodeOutputHint().should('exist');
|
||||
ndv.actions.close();
|
||||
|
||||
// Unpin using shortcut
|
||||
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
|
||||
ndv.actions.setPinnedData([{ test: 1 }]);
|
||||
ndv.actions.close();
|
||||
workflowPage.getters.canvasNodeByName(EDIT_FIELDS_SET_NODE_NAME).click();
|
||||
workflowPage.actions.hitPinNodeShortcut();
|
||||
workflowPage.actions.openNode(EDIT_FIELDS_SET_NODE_NAME);
|
||||
ndv.getters.nodeOutputHint().should('exist');
|
||||
});
|
||||
|
||||
it('Should show an error when maximum pin data size is exceeded', () => {
|
||||
workflowPage.actions.addInitialNodeToCanvas('Schedule Trigger');
|
||||
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true);
|
||||
|
@ -217,32 +184,6 @@ describe('Data pinning', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should show pinned data tooltip', () => {
|
||||
const { callEndpoint } = simpleWebhookCall({
|
||||
method: 'GET',
|
||||
webhookPath: nanoid(),
|
||||
executeNow: false,
|
||||
});
|
||||
|
||||
ndv.actions.close();
|
||||
workflowPage.actions.executeWorkflow();
|
||||
cy.wait(waitForWebhook);
|
||||
|
||||
// hide other visible popper on workflow execute button
|
||||
workflowPage.getters.canvasNodes().eq(0).click();
|
||||
|
||||
callEndpoint((response) => {
|
||||
expect(response.status).to.eq(200);
|
||||
getVisiblePopper().should('have.length', 1);
|
||||
getVisiblePopper()
|
||||
.eq(0)
|
||||
.should(
|
||||
'have.text',
|
||||
'You can pin this output instead of waiting for a test event. Open node to do so.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show pinned data tooltip', () => {
|
||||
cy.createFixtureWorkflow('Pinned_webhook_node.json', 'Test');
|
||||
workflowPage.actions.executeWorkflow();
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { simpleWebhookCall, waitForWebhook } from '../composables/webhooks';
|
||||
import { BACKEND_BASE_URL, EDIT_FIELDS_SET_NODE_NAME } from '../constants';
|
||||
import { WorkflowPage, NDV, CredentialsModal } from '../pages';
|
||||
import { cowBase64 } from '../support/binaryTestFiles';
|
||||
|
@ -9,81 +10,6 @@ const workflowPage = new WorkflowPage();
|
|||
const ndv = new NDV();
|
||||
const credentialsModal = new CredentialsModal();
|
||||
|
||||
export const waitForWebhook = 500;
|
||||
|
||||
interface SimpleWebhookCallOptions {
|
||||
method: string;
|
||||
webhookPath: string;
|
||||
responseCode?: number;
|
||||
respondWith?: string;
|
||||
executeNow?: boolean;
|
||||
responseData?: string;
|
||||
authentication?: string;
|
||||
}
|
||||
|
||||
export const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
|
||||
const {
|
||||
authentication,
|
||||
method,
|
||||
webhookPath,
|
||||
responseCode,
|
||||
respondWith,
|
||||
responseData,
|
||||
executeNow = true,
|
||||
} = options;
|
||||
|
||||
workflowPage.actions.addInitialNodeToCanvas('Webhook');
|
||||
workflowPage.actions.openNode('Webhook');
|
||||
|
||||
cy.getByTestId('parameter-input-httpMethod').click();
|
||||
getVisibleSelect().find('.option-headline').contains(method).click();
|
||||
cy.getByTestId('parameter-input-path')
|
||||
.find('.parameter-input')
|
||||
.find('input')
|
||||
.clear()
|
||||
.type(webhookPath);
|
||||
|
||||
if (authentication) {
|
||||
cy.getByTestId('parameter-input-authentication').click();
|
||||
getVisibleSelect().find('.option-headline').contains(authentication).click();
|
||||
}
|
||||
|
||||
if (responseCode) {
|
||||
cy.get('.param-options').click();
|
||||
getVisibleSelect().contains('Response Code').click();
|
||||
cy.get('.parameter-item-wrapper > .parameter-input-list-wrapper').children().click();
|
||||
getVisibleSelect().contains('201').click();
|
||||
}
|
||||
|
||||
if (respondWith) {
|
||||
cy.getByTestId('parameter-input-responseMode').click();
|
||||
getVisibleSelect().find('.option-headline').contains(respondWith).click();
|
||||
}
|
||||
|
||||
if (responseData) {
|
||||
cy.getByTestId('parameter-input-responseData').click();
|
||||
getVisibleSelect().find('.option-headline').contains(responseData).click();
|
||||
}
|
||||
|
||||
const callEndpoint = (cb: (response: Cypress.Response<unknown>) => void) => {
|
||||
cy.request(method, `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then(cb);
|
||||
};
|
||||
|
||||
if (executeNow) {
|
||||
ndv.actions.execute();
|
||||
cy.wait(waitForWebhook);
|
||||
|
||||
callEndpoint((response) => {
|
||||
expect(response.status).to.eq(200);
|
||||
ndv.getters.outputPanel().contains('headers');
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
callEndpoint,
|
||||
};
|
||||
};
|
||||
|
||||
describe('Webhook Trigger node', () => {
|
||||
beforeEach(() => {
|
||||
workflowPage.actions.visit();
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
|
||||
|
||||
const WorkflowPage = new WorkflowPageClass();
|
||||
|
||||
describe('PAY-1858 context menu', () => {
|
||||
it('can use context menu on saved workflow', () => {
|
||||
WorkflowPage.actions.visit();
|
||||
cy.createFixtureWorkflow('Test_workflow_filter.json', 'test');
|
||||
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 5);
|
||||
WorkflowPage.actions.deleteNodeFromContextMenu('Then');
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 4);
|
||||
|
||||
WorkflowPage.actions.hitSaveWorkflow();
|
||||
|
||||
cy.reload();
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 4);
|
||||
WorkflowPage.actions.deleteNodeFromContextMenu('Code');
|
||||
WorkflowPage.getters.canvasNodes().should('have.length', 3);
|
||||
});
|
||||
});
|
|
@ -214,91 +214,6 @@ describe('Execution', () => {
|
|||
workflowPage.getters.clearExecutionDataButton().should('not.exist');
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Webhook should show waiting state but it doesn't
|
||||
it('should test webhook workflow stop', () => {
|
||||
cy.createFixtureWorkflow('Webhook_wait_set.json');
|
||||
|
||||
// Check workflow buttons
|
||||
workflowPage.getters.executeWorkflowButton().should('be.visible');
|
||||
workflowPage.getters.clearExecutionDataButton().should('not.exist');
|
||||
workflowPage.getters.stopExecutionButton().should('not.exist');
|
||||
workflowPage.getters.stopExecutionWaitingForWebhookButton().should('not.exist');
|
||||
|
||||
// Execute the workflow
|
||||
workflowPage.getters.zoomToFitButton().click();
|
||||
workflowPage.getters.executeWorkflowButton().click();
|
||||
|
||||
// Check workflow buttons
|
||||
workflowPage.getters.executeWorkflowButton().get('.n8n-spinner').should('be.visible');
|
||||
workflowPage.getters.clearExecutionDataButton().should('not.exist');
|
||||
workflowPage.getters.stopExecutionButton().should('not.exist');
|
||||
workflowPage.getters.stopExecutionWaitingForWebhookButton().should('be.visible');
|
||||
|
||||
workflowPage.getters.canvasNodes().first().dblclick();
|
||||
|
||||
ndv.getters.copyInput().click();
|
||||
|
||||
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
|
||||
|
||||
ndv.getters.backToCanvas().click();
|
||||
|
||||
cy.readClipboard().then((url) => {
|
||||
cy.request({
|
||||
method: 'GET',
|
||||
url,
|
||||
}).then((resp) => {
|
||||
expect(resp.status).to.eq(200);
|
||||
});
|
||||
});
|
||||
|
||||
successToast().should('be.visible');
|
||||
clearNotifications();
|
||||
|
||||
workflowPage.getters.stopExecutionButton().click();
|
||||
// Check canvas nodes after 1st step (workflow passed the manual trigger node
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('Webhook')
|
||||
.within(() => cy.get('.fa-check'))
|
||||
.should('exist');
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('Wait')
|
||||
.within(() => cy.get('.fa-check').should('not.exist'));
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('Wait')
|
||||
.within(() => cy.get('.fa-sync-alt'))
|
||||
.should('exist');
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('Set')
|
||||
.within(() => cy.get('.fa-check').should('not.exist'));
|
||||
|
||||
// Check canvas nodes after workflow stopped
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('Webhook')
|
||||
.within(() => cy.get('.fa-check'))
|
||||
.should('exist');
|
||||
|
||||
if (isCanvasV2()) {
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('Wait')
|
||||
.within(() => cy.get('.fa-sync-alt').should('not.exist'));
|
||||
} else {
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('Wait')
|
||||
.within(() => cy.get('.fa-sync-alt').should('not.be.visible'));
|
||||
}
|
||||
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('Set')
|
||||
.within(() => cy.get('.fa-check').should('not.exist'));
|
||||
|
||||
successToast().should('be.visible');
|
||||
|
||||
// Clear execution data
|
||||
workflowPage.getters.clearExecutionDataButton().should('be.visible');
|
||||
workflowPage.getters.clearExecutionDataButton().click();
|
||||
workflowPage.getters.clearExecutionDataButton().should('not.exist');
|
||||
});
|
||||
|
||||
describe('execution preview', () => {
|
||||
it('when deleting the last execution, it should show empty state', () => {
|
||||
workflowPage.actions.addInitialNodeToCanvas('Manual Trigger');
|
||||
|
@ -312,8 +227,11 @@ describe('Execution', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// FIXME: Canvas V2: Missing pinned states for `edge-label-wrapper`
|
||||
describe('connections should be colored differently for pinned data', () => {
|
||||
/**
|
||||
* @TODO New Canvas: Different classes for pinned states on edges and nodes
|
||||
*/
|
||||
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
|
||||
describe.skip('connections should be colored differently for pinned data', () => {
|
||||
beforeEach(() => {
|
||||
cy.createFixtureWorkflow('Schedule_pinned.json');
|
||||
workflowPage.actions.deselectAll();
|
||||
|
@ -634,45 +552,4 @@ describe('Execution', () => {
|
|||
|
||||
errorToast().should('contain', 'Problem in node ‘Telegram‘');
|
||||
});
|
||||
|
||||
it('should not show pinned data in production execution', () => {
|
||||
cy.createFixtureWorkflow('Execution-pinned-data-check.json');
|
||||
|
||||
workflowPage.getters.zoomToFitButton().click();
|
||||
cy.intercept('PATCH', '/rest/workflows/*').as('workflowActivate');
|
||||
workflowPage.getters.activatorSwitch().click();
|
||||
|
||||
cy.wait('@workflowActivate');
|
||||
cy.get('body').type('{esc}');
|
||||
workflowPage.actions.openNode('Webhook');
|
||||
|
||||
cy.contains('label', 'Production URL').should('be.visible').click();
|
||||
cy.grantBrowserPermissions('clipboardReadWrite', 'clipboardSanitizedWrite');
|
||||
cy.get('.webhook-url').click();
|
||||
ndv.getters.backToCanvas().click();
|
||||
|
||||
cy.readClipboard().then((url) => {
|
||||
cy.request({
|
||||
method: 'GET',
|
||||
url,
|
||||
}).then((resp) => {
|
||||
expect(resp.status).to.eq(200);
|
||||
});
|
||||
});
|
||||
|
||||
cy.intercept('GET', '/rest/executions/*').as('getExecution');
|
||||
executionsTab.actions.switchToExecutionsTab();
|
||||
|
||||
cy.wait('@getExecution');
|
||||
executionsTab.getters
|
||||
.workflowExecutionPreviewIframe()
|
||||
.should('be.visible')
|
||||
.its('0.contentDocument.body')
|
||||
.should('not.be.empty')
|
||||
|
||||
.then(cy.wrap)
|
||||
.find('.connection-run-items-label')
|
||||
.filter(':contains("5 items")')
|
||||
.should('have.length', 2);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
import { WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
|
||||
|
||||
const workflowPage = new WorkflowPageClass();
|
||||
const executionsTab = new WorkflowExecutionsTab();
|
||||
|
||||
describe('ADO-2106 connections should be colored correctly for pinned data in executions preview', () => {
|
||||
beforeEach(() => {
|
||||
workflowPage.actions.visit();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
cy.createFixtureWorkflow('Webhook_set_pinned.json');
|
||||
workflowPage.actions.deselectAll();
|
||||
workflowPage.getters.zoomToFitButton().click();
|
||||
|
||||
workflowPage.getters.getConnectionBetweenNodes('Webhook', 'Set').should('have.class', 'pinned');
|
||||
});
|
||||
|
||||
it('should color connections for pinned data nodes for manual executions', () => {
|
||||
workflowPage.actions.executeWorkflow();
|
||||
|
||||
executionsTab.actions.switchToExecutionsTab();
|
||||
|
||||
executionsTab.getters.successfulExecutionListItems().should('have.length', 1);
|
||||
|
||||
executionsTab.getters
|
||||
.workflowExecutionPreviewIframe()
|
||||
.should('be.visible')
|
||||
.its('0.contentDocument.body')
|
||||
.should('not.be.empty')
|
||||
|
||||
.then(cy.wrap)
|
||||
.find('.jtk-connector[data-source-node="Webhook"][data-target-node="Set"]')
|
||||
.should('have.class', 'success')
|
||||
.should('have.class', 'has-run')
|
||||
.should('have.class', 'pinned');
|
||||
});
|
||||
});
|
|
@ -1,135 +0,0 @@
|
|||
import { WorkflowPage, NDV } from '../pages';
|
||||
|
||||
const workflowPage = new WorkflowPage();
|
||||
const ndv = new NDV();
|
||||
|
||||
describe('ADO-2111 expressions should support pinned data', () => {
|
||||
beforeEach(() => {
|
||||
workflowPage.actions.visit();
|
||||
});
|
||||
|
||||
it('supports pinned data in expressions unexecuted and executed parent nodes', () => {
|
||||
cy.createFixtureWorkflow('Test_workflow_pinned_data_in_expressions.json', 'Expressions');
|
||||
|
||||
// test previous node unexecuted
|
||||
workflowPage.actions.openNode('NotPinnedWithExpressions');
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(0)
|
||||
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(1)
|
||||
.should('contain.text', '0,0\nJoe\n\nJoe\n\nJoe\n\nJoe\nJoe');
|
||||
|
||||
// test can resolve correctly based on item
|
||||
ndv.actions.switchInputMode('Table');
|
||||
|
||||
ndv.getters.inputTableRow(2).realHover();
|
||||
cy.wait(50);
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(0)
|
||||
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(1)
|
||||
.should('contain.text', '0,1\nJoan\n\nJoan\n\nJoan\n\nJoan\nJoan');
|
||||
|
||||
// test previous node executed
|
||||
ndv.actions.execute();
|
||||
ndv.getters.inputTableRow(1).realHover();
|
||||
cy.wait(50);
|
||||
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(0)
|
||||
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
|
||||
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(1)
|
||||
.should('contain.text', '0,0\nJoe\n\nJoe\n\nJoe\n\nJoe\nJoe');
|
||||
|
||||
ndv.getters.inputTableRow(2).realHover();
|
||||
cy.wait(50);
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(0)
|
||||
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(1)
|
||||
.should('contain.text', '0,1\nJoan\n\nJoan\n\nJoan\n\nJoan\nJoan');
|
||||
|
||||
// check it resolved correctly on the backend
|
||||
ndv.getters
|
||||
.outputTbodyCell(1, 0)
|
||||
.should('contain.text', 'Joe\\nJoe\\nJoan\\nJoan\\nJoe\\nJoan\\n\\nJoe\\nJoan\\n\\nJoe');
|
||||
|
||||
ndv.getters
|
||||
.outputTbodyCell(2, 0)
|
||||
.should('contain.text', 'Joe\\nJoe\\nJoan\\nJoan\\nJoe\\nJoan\\n\\nJoe\\nJoan\\n\\nJoe');
|
||||
|
||||
ndv.getters
|
||||
.outputTbodyCell(1, 1)
|
||||
.should('contain.text', '0,0\\nJoe\\n\\nJoe\\n\\nJoe\\n\\nJoe\\nJoe');
|
||||
|
||||
ndv.getters
|
||||
.outputTbodyCell(2, 1)
|
||||
.should('contain.text', '0,1\\nJoan\\n\\nJoan\\n\\nJoan\\n\\nJoan\\nJoan');
|
||||
});
|
||||
|
||||
it('resets expressions after node is unpinned', () => {
|
||||
cy.createFixtureWorkflow('Test_workflow_pinned_data_in_expressions.json', 'Expressions');
|
||||
|
||||
// test previous node unexecuted
|
||||
workflowPage.actions.openNode('NotPinnedWithExpressions');
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(0)
|
||||
.should('include.text', 'Joe\nJoe\nJoan\nJoan\nJoe\nJoan\n\nJoe\nJoan\n\nJoe');
|
||||
ndv.getters
|
||||
.parameterExpressionPreview('value')
|
||||
.eq(1)
|
||||
.should('contain.text', '0,0\nJoe\n\nJoe\n\nJoe\n\nJoe\nJoe');
|
||||
|
||||
ndv.actions.close();
|
||||
|
||||
// unpin pinned node
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('PinnedSet')
|
||||
.eq(0)
|
||||
.find('.node-pin-data-icon')
|
||||
.should('exist');
|
||||
workflowPage.getters.canvasNodeByName('PinnedSet').eq(0).click();
|
||||
workflowPage.actions.hitPinNodeShortcut();
|
||||
workflowPage.getters
|
||||
.canvasNodeByName('PinnedSet')
|
||||
.eq(0)
|
||||
.find('.node-pin-data-icon')
|
||||
.should('not.exist');
|
||||
|
||||
workflowPage.actions.openNode('NotPinnedWithExpressions');
|
||||
ndv.getters.nodeParameters().find('parameter-expression-preview-value').should('not.exist');
|
||||
|
||||
ndv.getters.parameterInput('value').eq(0).click();
|
||||
ndv.getters
|
||||
.inlineExpressionEditorOutput()
|
||||
.should(
|
||||
'have.text',
|
||||
'[Execute node ‘PinnedSet’ for preview][Execute node ‘PinnedSet’ for preview][Execute node ‘PinnedSet’ for preview][Execute node ‘PinnedSet’ for preview][Execute node ‘PinnedSet’ for preview][Execute node ‘PinnedSet’ for preview][Execute previous nodes for preview][Execute previous nodes for preview][undefined]',
|
||||
);
|
||||
|
||||
// close open expression
|
||||
ndv.getters.inputLabel().eq(0).click();
|
||||
|
||||
ndv.getters.parameterInput('value').eq(1).click();
|
||||
ndv.getters
|
||||
.inlineExpressionEditorOutput()
|
||||
.should(
|
||||
'have.text',
|
||||
'0,0[Execute node ‘PinnedSet’ for preview][Execute node ‘PinnedSet’ for preview][Execute previous nodes for preview][Execute previous nodes for preview][Execute previous nodes for preview]',
|
||||
);
|
||||
});
|
||||
});
|
|
@ -65,8 +65,11 @@ describe('Variables', () => {
|
|||
const editingRow = variablesPage.getters.variablesEditableRows().eq(0);
|
||||
variablesPage.actions.setRowValue(editingRow, 'key', key);
|
||||
variablesPage.actions.setRowValue(editingRow, 'value', value);
|
||||
editingRow.should('contain', 'This field may contain only letters');
|
||||
variablesPage.getters.editableRowSaveButton(editingRow).should('be.disabled');
|
||||
variablesPage.actions.saveRowEditing(editingRow);
|
||||
variablesPage.getters
|
||||
.variablesEditableRows()
|
||||
.eq(0)
|
||||
.should('contain', 'This field may contain only letters');
|
||||
variablesPage.actions.cancelRowEditing(editingRow);
|
||||
|
||||
variablesPage.getters.variablesRows().should('have.length', 3);
|
||||
|
|
|
@ -118,6 +118,15 @@ describe('NDV', () => {
|
|||
ndv.actions.switchInputMode('Table');
|
||||
ndv.actions.switchOutputMode('Table');
|
||||
|
||||
// Start from linked state
|
||||
ndv.getters.outputLinkRun().then(($el) => {
|
||||
const classList = Array.from($el[0].classList);
|
||||
if (!classList.includes('linked')) {
|
||||
ndv.actions.toggleOutputRunLinking();
|
||||
ndv.getters.inputTbodyCell(1, 0).click(); // remove tooltip
|
||||
}
|
||||
});
|
||||
|
||||
ndv.getters
|
||||
.inputRunSelector()
|
||||
.should('exist')
|
||||
|
@ -243,38 +252,38 @@ describe('NDV', () => {
|
|||
// biome-ignore format:
|
||||
const PINNED_DATA = [
|
||||
{
|
||||
"id": "abc",
|
||||
"historyId": "def",
|
||||
"messages": [
|
||||
id: 'abc',
|
||||
historyId: 'def',
|
||||
messages: [
|
||||
{
|
||||
"id": "abc"
|
||||
}
|
||||
]
|
||||
id: 'abc',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "abc",
|
||||
"historyId": "def",
|
||||
"messages": [
|
||||
id: 'abc',
|
||||
historyId: 'def',
|
||||
messages: [
|
||||
{
|
||||
"id": "abc"
|
||||
id: 'abc',
|
||||
},
|
||||
{
|
||||
"id": "abc"
|
||||
id: 'abc',
|
||||
},
|
||||
{
|
||||
"id": "abc"
|
||||
}
|
||||
]
|
||||
id: 'abc',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "abc",
|
||||
"historyId": "def",
|
||||
"messages": [
|
||||
id: 'abc',
|
||||
historyId: 'def',
|
||||
messages: [
|
||||
{
|
||||
"id": "abc"
|
||||
}
|
||||
]
|
||||
}
|
||||
id: 'abc',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
workflowPage.actions.openNode('Get thread details1');
|
||||
ndv.actions.pastePinnedData(PINNED_DATA);
|
||||
|
|
|
@ -3,24 +3,6 @@ import { WorkflowPage as WorkflowPageClass } from '../pages/workflow';
|
|||
|
||||
const workflowPage = new WorkflowPageClass();
|
||||
|
||||
function checkStickiesStyle(
|
||||
top: number,
|
||||
left: number,
|
||||
height: number,
|
||||
width: number,
|
||||
zIndex?: number,
|
||||
) {
|
||||
workflowPage.getters.stickies().should(($el) => {
|
||||
expect($el).to.have.css('top', `${top}px`);
|
||||
expect($el).to.have.css('left', `${left}px`);
|
||||
expect($el).to.have.css('height', `${height}px`);
|
||||
expect($el).to.have.css('width', `${width}px`);
|
||||
if (zIndex) {
|
||||
expect($el).to.have.css('z-index', `${zIndex}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('Canvas Actions', () => {
|
||||
beforeEach(() => {
|
||||
workflowPage.actions.visit();
|
||||
|
@ -51,191 +33,8 @@ describe('Canvas Actions', () => {
|
|||
.contains('Guide')
|
||||
.should('have.attr', 'href');
|
||||
});
|
||||
|
||||
it('drags sticky around to top left corner', () => {
|
||||
// used to caliberate move sticky function
|
||||
addDefaultSticky();
|
||||
moveSticky({ top: 0, left: 0 });
|
||||
});
|
||||
|
||||
it('drags sticky around and position/size are saved correctly', () => {
|
||||
addDefaultSticky();
|
||||
moveSticky({ top: 500, left: 500 });
|
||||
|
||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||
cy.wait('@createWorkflow');
|
||||
|
||||
cy.reload();
|
||||
cy.waitForLoad();
|
||||
|
||||
stickyShouldBePositionedCorrectly({ top: 500, left: 500 });
|
||||
});
|
||||
|
||||
it('deletes sticky', () => {
|
||||
workflowPage.actions.addSticky();
|
||||
workflowPage.getters.stickies().should('have.length', 1);
|
||||
|
||||
workflowPage.actions.deleteSticky();
|
||||
|
||||
workflowPage.getters.stickies().should('have.length', 0);
|
||||
});
|
||||
|
||||
it('edits sticky and updates content as markdown', () => {
|
||||
workflowPage.actions.addSticky();
|
||||
|
||||
workflowPage.getters
|
||||
.stickies()
|
||||
.should('have.text', 'I’m a note\nDouble click to edit me. Guide\n');
|
||||
|
||||
workflowPage.getters.stickies().dblclick();
|
||||
workflowPage.actions.editSticky('# hello world \n ## text text');
|
||||
workflowPage.getters.stickies().find('h1').should('have.text', 'hello world');
|
||||
workflowPage.getters.stickies().find('h2').should('have.text', 'text text');
|
||||
});
|
||||
|
||||
it('expands/shrinks sticky from the right edge', () => {
|
||||
addDefaultSticky();
|
||||
|
||||
moveSticky({ top: 200, left: 200 });
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="right"]', [100, 100]);
|
||||
checkStickiesStyle(100, 20, 160, 346);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="right"]', [-50, -50]);
|
||||
checkStickiesStyle(100, 20, 160, 302);
|
||||
});
|
||||
|
||||
it('expands/shrinks sticky from the left edge', () => {
|
||||
addDefaultSticky();
|
||||
|
||||
moveSticky({ left: 600, top: 200 });
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="left"]', [100, 100]);
|
||||
checkStickiesStyle(100, 510, 160, 150);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="left"]', [-50, -50]);
|
||||
checkStickiesStyle(100, 466, 160, 194);
|
||||
});
|
||||
|
||||
it('expands/shrinks sticky from the top edge', () => {
|
||||
workflowPage.actions.addSticky();
|
||||
cy.drag('[data-test-id="sticky"]', [100, 100]); // move away from canvas button
|
||||
checkStickiesStyle(300, 620, 160, 240);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="top"]', [100, 100]);
|
||||
checkStickiesStyle(380, 620, 80, 240);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="top"]', [-50, -50]);
|
||||
checkStickiesStyle(324, 620, 136, 240);
|
||||
});
|
||||
|
||||
it('expands/shrinks sticky from the bottom edge', () => {
|
||||
workflowPage.actions.addSticky();
|
||||
cy.drag('[data-test-id="sticky"]', [100, 100]); // move away from canvas button
|
||||
checkStickiesStyle(300, 620, 160, 240);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="bottom"]', [100, 100]);
|
||||
checkStickiesStyle(300, 620, 254, 240);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="bottom"]', [-50, -50]);
|
||||
checkStickiesStyle(300, 620, 198, 240);
|
||||
});
|
||||
|
||||
it('expands/shrinks sticky from the bottom right edge', () => {
|
||||
workflowPage.actions.addSticky();
|
||||
cy.drag('[data-test-id="sticky"]', [-100, -100]); // move away from canvas button
|
||||
checkStickiesStyle(100, 420, 160, 240);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="bottomRight"]', [100, 100]);
|
||||
checkStickiesStyle(100, 420, 254, 346);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="bottomRight"]', [-50, -50]);
|
||||
checkStickiesStyle(100, 420, 198, 302);
|
||||
});
|
||||
|
||||
it('expands/shrinks sticky from the top right edge', () => {
|
||||
addDefaultSticky();
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="topRight"]', [100, 100]);
|
||||
checkStickiesStyle(360, 400, 80, 346);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="topRight"]', [-50, -50]);
|
||||
checkStickiesStyle(304, 400, 136, 302);
|
||||
});
|
||||
|
||||
it('expands/shrinks sticky from the top left edge, and reach min height/width', () => {
|
||||
addDefaultSticky();
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="topLeft"]', [100, 100]);
|
||||
checkStickiesStyle(360, 490, 80, 150);
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="topLeft"]', [-150, -150]);
|
||||
checkStickiesStyle(204, 346, 236, 294);
|
||||
});
|
||||
|
||||
it('sets sticky behind node', () => {
|
||||
workflowPage.actions.addInitialNodeToCanvas('Manual Trigger');
|
||||
addDefaultSticky();
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="topLeft"]', [-150, -150]);
|
||||
checkStickiesStyle(124, 256, 316, 384, -121);
|
||||
|
||||
workflowPage.getters
|
||||
.canvasNodes()
|
||||
.eq(0)
|
||||
.should(($el) => {
|
||||
expect($el).to.have.css('z-index', 'auto');
|
||||
});
|
||||
|
||||
workflowPage.actions.addSticky();
|
||||
workflowPage.getters
|
||||
.stickies()
|
||||
.eq(0)
|
||||
.should(($el) => {
|
||||
expect($el).to.have.css('z-index', '-121');
|
||||
});
|
||||
workflowPage.getters
|
||||
.stickies()
|
||||
.eq(1)
|
||||
.should(($el) => {
|
||||
expect($el).to.have.css('z-index', '-38');
|
||||
});
|
||||
|
||||
cy.drag('[data-test-id="sticky"] [data-dir="topLeft"]', [-200, -200], { index: 1 });
|
||||
workflowPage.getters
|
||||
.stickies()
|
||||
.eq(0)
|
||||
.should(($el) => {
|
||||
expect($el).to.have.css('z-index', '-121');
|
||||
});
|
||||
|
||||
workflowPage.getters
|
||||
.stickies()
|
||||
.eq(1)
|
||||
.should(($el) => {
|
||||
expect($el).to.have.css('z-index', '-158');
|
||||
});
|
||||
});
|
||||
|
||||
it('Empty sticky should not error when activating workflow', () => {
|
||||
workflowPage.actions.addSticky();
|
||||
|
||||
workflowPage.getters.stickies().should('have.length', 1);
|
||||
|
||||
workflowPage.getters.stickies().dblclick();
|
||||
|
||||
workflowPage.actions.clearSticky();
|
||||
|
||||
workflowPage.actions.addNodeToCanvas('Schedule Trigger');
|
||||
|
||||
workflowPage.actions.activateWorkflow();
|
||||
});
|
||||
});
|
||||
|
||||
type Position = {
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
|
||||
function shouldHaveOneSticky() {
|
||||
workflowPage.getters.stickies().should('have.length', 1);
|
||||
}
|
||||
|
@ -263,17 +62,3 @@ function addDefaultSticky() {
|
|||
shouldHaveDefaultSize();
|
||||
shouldBeInDefaultLocation();
|
||||
}
|
||||
|
||||
function stickyShouldBePositionedCorrectly(position: Position) {
|
||||
const yOffset = -100;
|
||||
const xOffset = -180;
|
||||
workflowPage.getters.stickies().should(($el) => {
|
||||
expect($el).to.have.css('top', `${yOffset + position.top}px`);
|
||||
expect($el).to.have.css('left', `${xOffset + position.left}px`);
|
||||
});
|
||||
}
|
||||
|
||||
function moveSticky(target: Position) {
|
||||
cy.drag('[data-test-id="sticky"]', [target.left, target.top], { abs: true });
|
||||
stickyShouldBePositionedCorrectly(target);
|
||||
}
|
||||
|
|
|
@ -1,86 +1,7 @@
|
|||
import { getWorkflowHistoryCloseButton } from '../composables/workflow';
|
||||
import {
|
||||
CODE_NODE_NAME,
|
||||
EDIT_FIELDS_SET_NODE_NAME,
|
||||
IF_NODE_NAME,
|
||||
SCHEDULE_TRIGGER_NODE_NAME,
|
||||
} from '../constants';
|
||||
import { WorkflowExecutionsTab, WorkflowPage as WorkflowPageClass } from '../pages';
|
||||
import { WorkflowPage as WorkflowPageClass } from '../pages';
|
||||
|
||||
const workflowPage = new WorkflowPageClass();
|
||||
const executionsTab = new WorkflowExecutionsTab();
|
||||
|
||||
const createNewWorkflowAndActivate = () => {
|
||||
workflowPage.actions.visit();
|
||||
workflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||
workflowPage.actions.activateWorkflow();
|
||||
cy.get('.el-notification .el-notification--error').should('not.exist');
|
||||
};
|
||||
|
||||
const editWorkflowAndDeactivate = () => {
|
||||
workflowPage.getters.canvasNodePlusEndpointByName(SCHEDULE_TRIGGER_NODE_NAME).click();
|
||||
workflowPage.getters.nodeCreatorSearchBar().should('be.visible');
|
||||
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, false);
|
||||
cy.get('.jtk-connector').should('have.length', 1);
|
||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||
workflowPage.getters.activatorSwitch().click();
|
||||
workflowPage.actions.zoomToFit();
|
||||
cy.get('.el-notification .el-notification--error').should('not.exist');
|
||||
};
|
||||
|
||||
const editWorkflowMoreAndActivate = () => {
|
||||
cy.drag(workflowPage.getters.getEndpointSelector('plus', EDIT_FIELDS_SET_NODE_NAME), [200, 200], {
|
||||
realMouse: true,
|
||||
});
|
||||
workflowPage.getters.nodeCreatorSearchBar().should('be.visible');
|
||||
|
||||
workflowPage.actions.addNodeToCanvas(CODE_NODE_NAME, false);
|
||||
workflowPage.getters.nodeViewBackground().click(600, 200, { force: true });
|
||||
cy.get('.jtk-connector').should('have.length', 2);
|
||||
workflowPage.actions.zoomToFit();
|
||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||
|
||||
workflowPage.actions.addNodeToCanvas(IF_NODE_NAME);
|
||||
workflowPage.getters.nodeViewBackground().click(600, 200, { force: true });
|
||||
cy.get('.jtk-connector').should('have.length', 2);
|
||||
|
||||
const position = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
};
|
||||
workflowPage.getters
|
||||
.canvasNodeByName(IF_NODE_NAME)
|
||||
.click()
|
||||
.then(($element) => {
|
||||
position.top = $element.position().top;
|
||||
position.left = $element.position().left;
|
||||
});
|
||||
|
||||
cy.drag('[data-test-id="canvas-node"].jtk-drag-selected', [50, 200], { clickToFinish: true });
|
||||
workflowPage.getters
|
||||
.canvasNodes()
|
||||
.last()
|
||||
.then(($element) => {
|
||||
const finalPosition = {
|
||||
top: $element.position().top,
|
||||
left: $element.position().left,
|
||||
};
|
||||
|
||||
expect(finalPosition.top).to.be.greaterThan(position.top);
|
||||
expect(finalPosition.left).to.be.greaterThan(position.left);
|
||||
});
|
||||
|
||||
cy.draganddrop(
|
||||
workflowPage.getters.getEndpointSelector('output', CODE_NODE_NAME),
|
||||
workflowPage.getters.getEndpointSelector('input', IF_NODE_NAME),
|
||||
);
|
||||
cy.get('.jtk-connector').should('have.length', 3);
|
||||
|
||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||
workflowPage.getters.activatorSwitch().click();
|
||||
cy.get('.el-notification .el-notification--error').should('not.exist');
|
||||
};
|
||||
|
||||
const switchBetweenEditorAndHistory = () => {
|
||||
workflowPage.getters.workflowHistoryButton().click();
|
||||
|
@ -116,62 +37,6 @@ const zoomInAndCheckNodes = () => {
|
|||
workflowPage.getters.canvasNodes().last().should('not.be.visible');
|
||||
};
|
||||
|
||||
describe('Editor actions should work', () => {
|
||||
beforeEach(() => {
|
||||
cy.enableFeature('debugInEditor');
|
||||
cy.enableFeature('workflowHistory');
|
||||
cy.signinAsOwner();
|
||||
createNewWorkflowAndActivate();
|
||||
});
|
||||
|
||||
it('after switching between Editor and Executions', () => {
|
||||
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
|
||||
|
||||
executionsTab.actions.switchToExecutionsTab();
|
||||
cy.wait(['@getExecutions']);
|
||||
cy.wait(500);
|
||||
executionsTab.actions.switchToEditorTab();
|
||||
editWorkflowAndDeactivate();
|
||||
editWorkflowMoreAndActivate();
|
||||
});
|
||||
|
||||
it('after switching between Editor and Debug', () => {
|
||||
cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions');
|
||||
cy.intercept('GET', '/rest/executions/*').as('getExecution');
|
||||
cy.intercept('POST', '/rest/workflows/**/run?**').as('postWorkflowRun');
|
||||
|
||||
editWorkflowAndDeactivate();
|
||||
workflowPage.actions.executeWorkflow();
|
||||
cy.wait(['@postWorkflowRun']);
|
||||
|
||||
executionsTab.actions.switchToExecutionsTab();
|
||||
cy.wait(['@getExecutions']);
|
||||
|
||||
executionsTab.getters.executionListItems().should('have.length', 1).first().click();
|
||||
cy.wait(['@getExecution']);
|
||||
|
||||
executionsTab.getters.executionDebugButton().should('have.text', 'Copy to editor').click();
|
||||
editWorkflowMoreAndActivate();
|
||||
});
|
||||
|
||||
it('after switching between Editor and Workflow history', () => {
|
||||
cy.intercept('GET', '/rest/workflow-history/workflow/*/version/*').as('getVersion');
|
||||
cy.intercept('GET', '/rest/workflow-history/workflow/*').as('getHistory');
|
||||
|
||||
editWorkflowAndDeactivate();
|
||||
workflowPage.getters.workflowHistoryButton().click();
|
||||
cy.wait(['@getHistory']);
|
||||
cy.wait(['@getVersion']);
|
||||
|
||||
cy.intercept('GET', '/rest/workflows/*').as('workflowGet');
|
||||
getWorkflowHistoryCloseButton().click();
|
||||
cy.wait(['@workflowGet']);
|
||||
cy.wait(1000);
|
||||
|
||||
editWorkflowMoreAndActivate();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editor zoom should work after route changes', () => {
|
||||
beforeEach(() => {
|
||||
cy.enableFeature('debugInEditor');
|
||||
|
|
|
@ -38,8 +38,6 @@ import {
|
|||
addToolNodeToParent,
|
||||
clickExecuteWorkflowButton,
|
||||
clickManualChatButton,
|
||||
disableNode,
|
||||
getExecuteWorkflowButton,
|
||||
navigateToNewWorkflowPage,
|
||||
getNodes,
|
||||
openNode,
|
||||
|
@ -73,27 +71,6 @@ describe('Langchain Integration', () => {
|
|||
getManualChatModal().should('not.exist');
|
||||
});
|
||||
|
||||
it('should disable test workflow button', () => {
|
||||
addNodeToCanvas('Schedule Trigger', true);
|
||||
addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true);
|
||||
|
||||
clickGetBackToCanvas();
|
||||
|
||||
addNodeToCanvas(AGENT_NODE_NAME, true, true);
|
||||
clickGetBackToCanvas();
|
||||
|
||||
addLanguageModelNodeToParent(
|
||||
AI_LANGUAGE_MODEL_OPENAI_CHAT_MODEL_NODE_NAME,
|
||||
AGENT_NODE_NAME,
|
||||
true,
|
||||
);
|
||||
clickGetBackToCanvas();
|
||||
|
||||
disableNode('Schedule Trigger');
|
||||
|
||||
getExecuteWorkflowButton().should('be.disabled');
|
||||
});
|
||||
|
||||
it('should add nodes to all Agent node input types', () => {
|
||||
addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME, true);
|
||||
addNodeToCanvas(AGENT_NODE_NAME, true, true);
|
||||
|
@ -368,58 +345,6 @@ describe('Langchain Integration', () => {
|
|||
getConnectionBySourceAndTarget(CHAT_TRIGGER_NODE_DISPLAY_NAME, AGENT_NODE_NAME).should('exist');
|
||||
getNodes().should('have.length', 3);
|
||||
});
|
||||
it('should render runItems for sub-nodes and allow switching between them', () => {
|
||||
const workflowPage = new WorkflowPage();
|
||||
const ndv = new NDV();
|
||||
|
||||
cy.visit(workflowPage.url);
|
||||
cy.createFixtureWorkflow('In_memory_vector_store_fake_embeddings.json');
|
||||
workflowPage.actions.zoomToFit();
|
||||
|
||||
workflowPage.actions.executeNode('Populate VS');
|
||||
cy.get('[data-label="25 items"]').should('exist');
|
||||
|
||||
const assertInputOutputText = (text: string, assertion: 'exist' | 'not.exist') => {
|
||||
ndv.getters.outputPanel().contains(text).should(assertion);
|
||||
ndv.getters.inputPanel().contains(text).should(assertion);
|
||||
};
|
||||
|
||||
workflowPage.actions.openNode('Character Text Splitter');
|
||||
ndv.getters.outputRunSelector().should('exist');
|
||||
ndv.getters.inputRunSelector().should('exist');
|
||||
ndv.getters.inputRunSelector().find('input').should('include.value', '3 of 3');
|
||||
ndv.getters.outputRunSelector().find('input').should('include.value', '3 of 3');
|
||||
assertInputOutputText('Kyiv', 'exist');
|
||||
assertInputOutputText('Berlin', 'not.exist');
|
||||
assertInputOutputText('Prague', 'not.exist');
|
||||
|
||||
ndv.actions.changeOutputRunSelector('2 of 3');
|
||||
assertInputOutputText('Berlin', 'exist');
|
||||
assertInputOutputText('Kyiv', 'not.exist');
|
||||
assertInputOutputText('Prague', 'not.exist');
|
||||
|
||||
ndv.actions.changeOutputRunSelector('1 of 3');
|
||||
assertInputOutputText('Prague', 'exist');
|
||||
assertInputOutputText('Berlin', 'not.exist');
|
||||
assertInputOutputText('Kyiv', 'not.exist');
|
||||
|
||||
ndv.actions.toggleInputRunLinking();
|
||||
ndv.actions.changeOutputRunSelector('2 of 3');
|
||||
ndv.getters.inputRunSelector().find('input').should('include.value', '1 of 3');
|
||||
ndv.getters.outputRunSelector().find('input').should('include.value', '2 of 3');
|
||||
ndv.getters.inputPanel().contains('Prague').should('exist');
|
||||
ndv.getters.inputPanel().contains('Berlin').should('not.exist');
|
||||
|
||||
ndv.getters.outputPanel().contains('Berlin').should('exist');
|
||||
ndv.getters.outputPanel().contains('Prague').should('not.exist');
|
||||
|
||||
ndv.actions.toggleInputRunLinking();
|
||||
ndv.getters.inputRunSelector().find('input').should('include.value', '1 of 3');
|
||||
ndv.getters.outputRunSelector().find('input').should('include.value', '1 of 3');
|
||||
assertInputOutputText('Prague', 'exist');
|
||||
assertInputOutputText('Berlin', 'not.exist');
|
||||
assertInputOutputText('Kyiv', 'not.exist');
|
||||
});
|
||||
|
||||
it('should show tool info notice if no existing tools were used during execution', () => {
|
||||
addNodeToCanvas(MANUAL_CHAT_TRIGGER_NODE_NAME, true);
|
||||
|
@ -518,4 +443,29 @@ describe('Langchain Integration', () => {
|
|||
|
||||
getRunDataInfoCallout().should('not.exist');
|
||||
});
|
||||
|
||||
it('should execute up to Node 1 when using partial execution', () => {
|
||||
const workflowPage = new WorkflowPage();
|
||||
const ndv = new NDV();
|
||||
|
||||
cy.visit(workflowPage.url);
|
||||
cy.createFixtureWorkflow('Test_workflow_chat_partial_execution.json');
|
||||
workflowPage.actions.zoomToFit();
|
||||
|
||||
getManualChatModal().should('not.exist');
|
||||
openNode('Node 1');
|
||||
ndv.actions.execute();
|
||||
|
||||
getManualChatModal().should('exist');
|
||||
sendManualChatMessage('Test');
|
||||
|
||||
getManualChatMessages().should('contain', 'this_my_field_1');
|
||||
cy.getByTestId('refresh-session-button').click();
|
||||
cy.get('button').contains('Reset').click();
|
||||
getManualChatMessages().should('not.exist');
|
||||
|
||||
sendManualChatMessage('Another test');
|
||||
getManualChatMessages().should('contain', 'this_my_field_3');
|
||||
getManualChatMessages().should('contain', 'this_my_field_4');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -11,18 +11,16 @@ import {
|
|||
WorkflowPage,
|
||||
CredentialsModal,
|
||||
CredentialsPage,
|
||||
WorkflowExecutionsTab,
|
||||
NDV,
|
||||
MainSidebar,
|
||||
} from '../pages';
|
||||
import { clearNotifications, successToast } from '../pages/notifications';
|
||||
import { getVisibleDropdown, getVisibleModalOverlay, getVisibleSelect } from '../utils';
|
||||
import { getVisibleSelect } from '../utils';
|
||||
|
||||
const workflowsPage = new WorkflowsPage();
|
||||
const workflowPage = new WorkflowPage();
|
||||
const credentialsPage = new CredentialsPage();
|
||||
const credentialsModal = new CredentialsModal();
|
||||
const executionsTab = new WorkflowExecutionsTab();
|
||||
const ndv = new NDV();
|
||||
const mainSidebar = new MainSidebar();
|
||||
|
||||
|
@ -36,207 +34,6 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
cy.changeQuota('maxTeamProjects', -1);
|
||||
});
|
||||
|
||||
it('should handle workflows and credentials and menu items', () => {
|
||||
cy.signinAsAdmin();
|
||||
cy.visit(workflowsPage.url);
|
||||
workflowsPage.getters.workflowCards().should('not.have.length');
|
||||
|
||||
workflowsPage.getters.newWorkflowButtonCard().click();
|
||||
|
||||
cy.intercept('POST', '/rest/workflows').as('workflowSave');
|
||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||
|
||||
cy.wait('@workflowSave').then((interception) => {
|
||||
expect(interception.request.body).not.to.have.property('projectId');
|
||||
});
|
||||
|
||||
projects.getHomeButton().click();
|
||||
projects.getProjectTabs().should('have.length', 3);
|
||||
|
||||
projects.getProjectTabCredentials().click();
|
||||
credentialsPage.getters.credentialCards().should('not.have.length');
|
||||
|
||||
credentialsPage.getters.emptyListCreateCredentialButton().click();
|
||||
credentialsModal.getters.newCredentialModal().should('be.visible');
|
||||
credentialsModal.getters.newCredentialTypeSelect().should('be.visible');
|
||||
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
|
||||
credentialsModal.getters.newCredentialTypeButton().click();
|
||||
credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890');
|
||||
credentialsModal.actions.setName('My awesome Notion account');
|
||||
|
||||
cy.intercept('POST', '/rest/credentials').as('credentialSave');
|
||||
credentialsModal.actions.save();
|
||||
cy.wait('@credentialSave').then((interception) => {
|
||||
expect(interception.request.body).not.to.have.property('projectId');
|
||||
});
|
||||
|
||||
credentialsModal.actions.close();
|
||||
credentialsPage.getters.credentialCards().should('have.length', 1);
|
||||
credentialsPage.getters
|
||||
.credentialCards()
|
||||
.first()
|
||||
.find('.n8n-node-icon img')
|
||||
.should('be.visible');
|
||||
|
||||
projects.getProjectTabWorkflows().click();
|
||||
workflowsPage.getters.workflowCards().should('have.length', 1);
|
||||
|
||||
projects.getMenuItems().should('not.have.length');
|
||||
|
||||
cy.intercept('POST', '/rest/projects').as('projectCreate');
|
||||
projects.getAddProjectButton().click();
|
||||
cy.wait('@projectCreate');
|
||||
projects.getMenuItems().should('have.length', 1);
|
||||
projects.getProjectTabs().should('have.length', 3);
|
||||
|
||||
cy.get('input[name="name"]').type('Development');
|
||||
projects.addProjectMember(INSTANCE_MEMBERS[0].email);
|
||||
|
||||
cy.intercept('PATCH', '/rest/projects/*').as('projectSettingsSave');
|
||||
projects.getProjectSettingsSaveButton().click();
|
||||
cy.wait('@projectSettingsSave').then((interception) => {
|
||||
expect(interception.request.body).to.have.property('name').and.to.equal('Development');
|
||||
expect(interception.request.body).to.have.property('relations').to.have.lengthOf(2);
|
||||
});
|
||||
|
||||
projects.getMenuItems().first().click();
|
||||
workflowsPage.getters.workflowCards().should('not.have.length');
|
||||
projects.getProjectTabs().should('have.length', 4);
|
||||
|
||||
workflowsPage.getters.newWorkflowButtonCard().click();
|
||||
|
||||
cy.intercept('POST', '/rest/workflows').as('workflowSave');
|
||||
workflowPage.actions.saveWorkflowOnButtonClick();
|
||||
|
||||
cy.wait('@workflowSave').then((interception) => {
|
||||
expect(interception.request.body).to.have.property('projectId');
|
||||
});
|
||||
|
||||
projects.getMenuItems().first().click();
|
||||
|
||||
projects.getProjectTabCredentials().click();
|
||||
credentialsPage.getters.credentialCards().should('not.have.length');
|
||||
|
||||
credentialsPage.getters.emptyListCreateCredentialButton().click();
|
||||
credentialsModal.getters.newCredentialModal().should('be.visible');
|
||||
credentialsModal.getters.newCredentialTypeSelect().should('be.visible');
|
||||
credentialsModal.getters.newCredentialTypeOption('Notion API').click();
|
||||
credentialsModal.getters.newCredentialTypeButton().click();
|
||||
credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890');
|
||||
credentialsModal.actions.setName('My awesome Notion account');
|
||||
|
||||
cy.intercept('POST', '/rest/credentials').as('credentialSave');
|
||||
credentialsModal.actions.save();
|
||||
cy.wait('@credentialSave').then((interception) => {
|
||||
expect(interception.request.body).to.have.property('projectId');
|
||||
});
|
||||
credentialsModal.actions.close();
|
||||
|
||||
projects.getAddProjectButton().click();
|
||||
projects.getMenuItems().should('have.length', 2);
|
||||
|
||||
let projectId: string;
|
||||
projects.getMenuItems().first().click();
|
||||
cy.intercept('GET', '/rest/credentials*').as('credentialsList');
|
||||
projects.getProjectTabCredentials().click();
|
||||
cy.wait('@credentialsList').then((interception) => {
|
||||
const url = new URL(interception.request.url);
|
||||
const queryParams = new URLSearchParams(url.search);
|
||||
const filter = queryParams.get('filter');
|
||||
expect(filter).to.be.a('string').and.to.contain('projectId');
|
||||
|
||||
if (filter) {
|
||||
projectId = JSON.parse(filter).projectId;
|
||||
}
|
||||
});
|
||||
|
||||
projects.getMenuItems().last().click();
|
||||
cy.intercept('GET', '/rest/credentials*').as('credentialsListProjectId');
|
||||
projects.getProjectTabCredentials().click();
|
||||
cy.wait('@credentialsListProjectId').then((interception) => {
|
||||
const url = new URL(interception.request.url);
|
||||
const queryParams = new URLSearchParams(url.search);
|
||||
const filter = queryParams.get('filter');
|
||||
expect(filter).to.be.a('string').and.to.contain('projectId');
|
||||
|
||||
if (filter) {
|
||||
expect(JSON.parse(filter).projectId).not.to.equal(projectId);
|
||||
}
|
||||
});
|
||||
|
||||
projects.getHomeButton().click();
|
||||
workflowsPage.getters.workflowCards().should('have.length', 2);
|
||||
|
||||
cy.intercept('GET', '/rest/credentials*').as('credentialsListUnfiltered');
|
||||
projects.getProjectTabCredentials().click();
|
||||
cy.wait('@credentialsListUnfiltered').then((interception) => {
|
||||
expect(interception.request.url).not.to.contain('filter');
|
||||
});
|
||||
|
||||
let menuItems = cy.getByTestId('menu-item');
|
||||
|
||||
menuItems.filter('[class*=active_]').should('have.length', 1);
|
||||
menuItems.filter(':contains("Overview")[class*=active_]').should('exist');
|
||||
|
||||
projects.getMenuItems().first().click();
|
||||
|
||||
menuItems = cy.getByTestId('menu-item');
|
||||
|
||||
menuItems.filter('[class*=active_]').should('have.length', 1);
|
||||
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
|
||||
|
||||
cy.intercept('GET', '/rest/workflows/*').as('loadWorkflow');
|
||||
workflowsPage.getters.workflowCards().first().findChildByTestId('card-content').click();
|
||||
|
||||
cy.wait('@loadWorkflow');
|
||||
menuItems = cy.getByTestId('menu-item');
|
||||
|
||||
menuItems.filter('[class*=active_]').should('have.length', 1);
|
||||
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
|
||||
|
||||
cy.intercept('GET', '/rest/executions*').as('loadExecutions');
|
||||
executionsTab.actions.switchToExecutionsTab();
|
||||
|
||||
cy.wait('@loadExecutions');
|
||||
menuItems = cy.getByTestId('menu-item');
|
||||
|
||||
menuItems.filter('[class*=active_]').should('have.length', 1);
|
||||
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
|
||||
|
||||
executionsTab.actions.switchToEditorTab();
|
||||
|
||||
menuItems = cy.getByTestId('menu-item');
|
||||
|
||||
menuItems.filter('[class*=active_]').should('have.length', 1);
|
||||
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
|
||||
|
||||
cy.getByTestId('menu-item').filter(':contains("Variables")').click();
|
||||
cy.getByTestId('unavailable-resources-list').should('be.visible');
|
||||
|
||||
menuItems = cy.getByTestId('menu-item');
|
||||
|
||||
menuItems.filter('[class*=active_]').should('have.length', 1);
|
||||
menuItems.filter(':contains("Variables")[class*=active_]').should('exist');
|
||||
|
||||
projects.getHomeButton().click();
|
||||
menuItems = cy.getByTestId('menu-item');
|
||||
|
||||
menuItems.filter('[class*=active_]').should('have.length', 1);
|
||||
menuItems.filter(':contains("Overview")[class*=active_]').should('exist');
|
||||
|
||||
workflowsPage.getters.workflowCards().should('have.length', 2).first().click();
|
||||
|
||||
cy.wait('@loadWorkflow');
|
||||
cy.getByTestId('execute-workflow-button').should('be.visible');
|
||||
|
||||
menuItems = cy.getByTestId('menu-item');
|
||||
menuItems.filter(':contains("Overview")[class*=active_]').should('not.exist');
|
||||
|
||||
menuItems = cy.getByTestId('menu-item');
|
||||
menuItems.filter('[class*=active_]').should('have.length', 1);
|
||||
menuItems.filter(':contains("Development")[class*=active_]').should('exist');
|
||||
});
|
||||
|
||||
it('should not show project add button and projects to a member if not invited to any project', () => {
|
||||
cy.signinAsMember(1);
|
||||
cy.visit(workflowsPage.url);
|
||||
|
@ -245,26 +42,6 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
projects.getMenuItems().should('not.exist');
|
||||
});
|
||||
|
||||
it('should not show viewer role if not licensed', () => {
|
||||
cy.signinAsOwner();
|
||||
cy.visit(workflowsPage.url);
|
||||
|
||||
projects.getMenuItems().first().click();
|
||||
projects.getProjectTabSettings().click();
|
||||
|
||||
cy.get(
|
||||
`[data-test-id="user-list-item-${INSTANCE_MEMBERS[0].email}"] [data-test-id="projects-settings-user-role-select"]`,
|
||||
).click();
|
||||
|
||||
cy.get('.el-select-dropdown__item.is-disabled')
|
||||
.should('contain.text', 'Viewer')
|
||||
.get('span:contains("Upgrade")')
|
||||
.filter(':visible')
|
||||
.click();
|
||||
|
||||
getVisibleModalOverlay().should('contain.text', 'Upgrade to unlock additional roles');
|
||||
});
|
||||
|
||||
describe('when starting from scratch', () => {
|
||||
beforeEach(() => {
|
||||
cy.resetDatabase();
|
||||
|
@ -275,7 +52,11 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
cy.changeQuota('maxTeamProjects', -1);
|
||||
});
|
||||
|
||||
it('should filter credentials by project ID when creating new workflow or hard reloading an opened workflow', () => {
|
||||
/**
|
||||
* @TODO: New Canvas - Fix this test
|
||||
*/
|
||||
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
|
||||
it.skip('should filter credentials by project ID when creating new workflow or hard reloading an opened workflow', () => {
|
||||
cy.signinAsOwner();
|
||||
cy.visit(workflowsPage.url);
|
||||
|
||||
|
@ -753,82 +534,6 @@ describe('Projects', { disableAutoLogin: true }, () => {
|
|||
workflowPage.getters.canvasNodeByName(NOTION_NODE_NAME).should('be.visible').dblclick();
|
||||
ndv.getters.credentialInput().find('input').should('be.enabled');
|
||||
});
|
||||
|
||||
it('should handle viewer role', () => {
|
||||
cy.enableFeature('projectRole:viewer');
|
||||
cy.signinAsOwner();
|
||||
cy.visit(workflowsPage.url);
|
||||
|
||||
projects.createProject('Development');
|
||||
projects.addProjectMember(INSTANCE_MEMBERS[0].email, 'Viewer');
|
||||
projects.getProjectSettingsSaveButton().click();
|
||||
|
||||
projects.getProjectTabWorkflows().click();
|
||||
workflowsPage.getters.newWorkflowButtonCard().click();
|
||||
projects.createWorkflow('Test_workflow_4_executions_view.json', 'WF with random error');
|
||||
executionsTab.actions.createManualExecutions(2);
|
||||
executionsTab.actions.toggleNodeEnabled('Error');
|
||||
executionsTab.actions.createManualExecutions(2);
|
||||
workflowPage.actions.saveWorkflowUsingKeyboardShortcut();
|
||||
|
||||
projects.getMenuItems().first().click();
|
||||
projects.getProjectTabCredentials().click();
|
||||
credentialsPage.getters.emptyListCreateCredentialButton().click();
|
||||
projects.createCredential('Notion API');
|
||||
|
||||
mainSidebar.actions.openUserMenu();
|
||||
cy.getByTestId('user-menu-item-logout').click();
|
||||
|
||||
cy.get('input[name="email"]').type(INSTANCE_MEMBERS[0].email);
|
||||
cy.get('input[name="password"]').type(INSTANCE_MEMBERS[0].password);
|
||||
cy.getByTestId('form-submit-button').click();
|
||||
|
||||
projects.getMenuItems().last().click();
|
||||
projects.getProjectTabExecutions().click();
|
||||
cy.getByTestId('global-execution-list-item').first().find('td:last button').click();
|
||||
getVisibleDropdown()
|
||||
.find('li')
|
||||
.filter(':contains("Retry")')
|
||||
.should('have.class', 'is-disabled');
|
||||
getVisibleDropdown()
|
||||
.find('li')
|
||||
.filter(':contains("Delete")')
|
||||
.should('have.class', 'is-disabled');
|
||||
|
||||
projects.getMenuItems().first().click();
|
||||
cy.getByTestId('workflow-card-name').should('be.visible').first().click();
|
||||
workflowPage.getters.nodeViewRoot().should('be.visible');
|
||||
workflowPage.getters.executeWorkflowButton().should('not.exist');
|
||||
workflowPage.getters.nodeCreatorPlusButton().should('not.exist');
|
||||
workflowPage.getters.canvasNodes().should('have.length', 3).last().click();
|
||||
cy.get('body').type('{backspace}');
|
||||
workflowPage.getters.canvasNodes().should('have.length', 3).last().rightclick();
|
||||
getVisibleDropdown()
|
||||
.find('li')
|
||||
.should('be.visible')
|
||||
.filter(
|
||||
':contains("Open"), :contains("Copy"), :contains("Select all"), :contains("Clear selection")',
|
||||
)
|
||||
.should('not.have.class', 'is-disabled');
|
||||
cy.get('body').type('{esc}');
|
||||
|
||||
executionsTab.actions.switchToExecutionsTab();
|
||||
cy.getByTestId('retry-execution-button')
|
||||
.should('be.visible')
|
||||
.find('.is-disabled')
|
||||
.should('exist');
|
||||
cy.get('button:contains("Debug")').should('be.disabled');
|
||||
cy.get('button[title="Retry execution"]').should('be.disabled');
|
||||
cy.get('button[title="Delete this execution"]').should('be.disabled');
|
||||
|
||||
projects.getMenuItems().first().click();
|
||||
projects.getProjectTabCredentials().click();
|
||||
credentialsPage.getters.credentialCards().filter(':contains("Notion")').click();
|
||||
cy.getByTestId('node-credentials-config-container')
|
||||
.should('be.visible')
|
||||
.find('input')
|
||||
.should('not.have.length');
|
||||
});
|
||||
});
|
||||
|
||||
it('should set and update project icon', () => {
|
||||
|
|
|
@ -1,9 +1,3 @@
|
|||
import {
|
||||
getExecutionPreviewOutputPanelRelatedExecutionLink,
|
||||
getExecutionsSidebar,
|
||||
getWorkflowExecutionPreviewIframe,
|
||||
openExecutionPreviewNode,
|
||||
} from '../composables/executions';
|
||||
import {
|
||||
changeOutputRunSelector,
|
||||
getOutputPanelItemsCount,
|
||||
|
@ -103,38 +97,4 @@ describe('Subworkflow debugging', () => {
|
|||
getOutputTbodyCell(1, 2).should('include.text', 'Terry.Dach@hotmail.com');
|
||||
});
|
||||
});
|
||||
|
||||
it('can inspect parent executions', () => {
|
||||
cy.url().then((workflowUrl) => {
|
||||
openNode('Execute Workflow with param');
|
||||
|
||||
getOutputPanelItemsCount().should('contain.text', '2 items, 1 sub-execution');
|
||||
getOutputPanelRelatedExecutionLink().should('contain.text', 'View sub-execution');
|
||||
getOutputPanelRelatedExecutionLink().should('have.attr', 'href');
|
||||
|
||||
// ensure workflow executed and waited on output
|
||||
getOutputTableHeaders().should('have.length', 2);
|
||||
getOutputTbodyCell(1, 0).should('have.text', 'world Natalie Moore');
|
||||
|
||||
// cypress cannot handle new tabs so removing it
|
||||
getOutputPanelRelatedExecutionLink().invoke('removeAttr', 'target').click();
|
||||
|
||||
getExecutionsSidebar().should('be.visible');
|
||||
getWorkflowExecutionPreviewIframe().should('be.visible');
|
||||
openExecutionPreviewNode('Execute Workflow Trigger');
|
||||
|
||||
getExecutionPreviewOutputPanelRelatedExecutionLink().should(
|
||||
'include.text',
|
||||
'View parent execution',
|
||||
);
|
||||
|
||||
getExecutionPreviewOutputPanelRelatedExecutionLink()
|
||||
.invoke('removeAttr', 'target')
|
||||
.click({ force: true });
|
||||
|
||||
cy.url().then((currentUrl) => {
|
||||
expect(currentUrl === workflowUrl);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import { setCredentialValues } from '../composables/modals/credential-modal';
|
||||
import { clickCreateNewCredential } from '../composables/ndv';
|
||||
import { MANUAL_TRIGGER_NODE_DISPLAY_NAME, NOTION_NODE_NAME } from '../constants';
|
||||
import { clickCreateNewCredential, setParameterSelectByContent } from '../composables/ndv';
|
||||
import {
|
||||
EDIT_FIELDS_SET_NODE_NAME,
|
||||
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
|
||||
NOTION_NODE_NAME,
|
||||
} from '../constants';
|
||||
import { NDV, WorkflowPage } from '../pages';
|
||||
import { NodeCreator } from '../pages/features/node-creator';
|
||||
|
||||
|
@ -106,7 +110,10 @@ describe('NDV', () => {
|
|||
ndv.actions.execute();
|
||||
ndv.getters
|
||||
.nodeRunErrorMessage()
|
||||
.should('have.text', 'Info for expression missing from previous node');
|
||||
.should(
|
||||
'have.text',
|
||||
"Using the item method doesn't work with pinned data in this scenario. Please unpin 'Break pairedItem chain' and try again.",
|
||||
);
|
||||
ndv.getters
|
||||
.nodeRunErrorDescription()
|
||||
.should(
|
||||
|
@ -242,6 +249,15 @@ describe('NDV', () => {
|
|||
ndv.actions.switchInputMode('Table');
|
||||
ndv.actions.switchOutputMode('Table');
|
||||
|
||||
// Start from linked state
|
||||
ndv.getters.outputLinkRun().then(($el) => {
|
||||
const classList = Array.from($el[0].classList);
|
||||
if (!classList.includes('linked')) {
|
||||
ndv.actions.toggleOutputRunLinking();
|
||||
ndv.getters.inputTbodyCell(1, 0).click(); // remove tooltip
|
||||
}
|
||||
});
|
||||
|
||||
ndv.getters
|
||||
.inputRunSelector()
|
||||
.should('exist')
|
||||
|
@ -359,15 +375,71 @@ describe('NDV', () => {
|
|||
ndv.getters.nodeExecuteButton().should('be.visible');
|
||||
});
|
||||
|
||||
it('should allow editing code in fullscreen in the Code node', () => {
|
||||
it('should allow editing code in fullscreen in the code editors', () => {
|
||||
// Code (JavaScript)
|
||||
workflowPage.actions.addInitialNodeToCanvas('Code', { keepNdvOpen: true });
|
||||
ndv.actions.openCodeEditorFullscreen();
|
||||
|
||||
ndv.getters.codeEditorFullscreen().type('{selectall}').type('{backspace}').type('foo()');
|
||||
ndv.getters.codeEditorFullscreen().should('contain.text', 'foo()');
|
||||
cy.wait(200);
|
||||
cy.wait(200); // allow change to emit before closing modal
|
||||
ndv.getters.codeEditorDialog().find('.el-dialog__close').click();
|
||||
ndv.getters.parameterInput('jsCode').get('.cm-content').should('contain.text', 'foo()');
|
||||
ndv.actions.close();
|
||||
|
||||
// SQL
|
||||
workflowPage.actions.addNodeToCanvas('Postgres', true, true, 'Execute a SQL query');
|
||||
ndv.actions.openCodeEditorFullscreen();
|
||||
|
||||
ndv.getters
|
||||
.codeEditorFullscreen()
|
||||
.type('{selectall}')
|
||||
.type('{backspace}')
|
||||
.type('SELECT * FROM workflows');
|
||||
ndv.getters.codeEditorFullscreen().should('contain.text', 'SELECT * FROM workflows');
|
||||
cy.wait(200);
|
||||
ndv.getters.codeEditorDialog().find('.el-dialog__close').click();
|
||||
ndv.getters
|
||||
.parameterInput('query')
|
||||
.get('.cm-content')
|
||||
.should('contain.text', 'SELECT * FROM workflows');
|
||||
ndv.actions.close();
|
||||
|
||||
// HTML
|
||||
workflowPage.actions.addNodeToCanvas('HTML', true, true, 'Generate HTML template');
|
||||
ndv.actions.openCodeEditorFullscreen();
|
||||
|
||||
ndv.getters
|
||||
.codeEditorFullscreen()
|
||||
.type('{selectall}')
|
||||
.type('{backspace}')
|
||||
.type('<div>Hello World');
|
||||
ndv.getters.codeEditorFullscreen().should('contain.text', '<div>Hello World</div>');
|
||||
cy.wait(200);
|
||||
|
||||
ndv.getters.codeEditorDialog().find('.el-dialog__close').click();
|
||||
ndv.getters
|
||||
.parameterInput('html')
|
||||
.get('.cm-content')
|
||||
.should('contain.text', '<div>Hello World</div>');
|
||||
ndv.actions.close();
|
||||
|
||||
// JSON
|
||||
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true);
|
||||
setParameterSelectByContent('mode', 'JSON');
|
||||
ndv.actions.openCodeEditorFullscreen();
|
||||
ndv.getters
|
||||
.codeEditorFullscreen()
|
||||
.type('{selectall}')
|
||||
.type('{backspace}')
|
||||
.type('{ "key": "value" }', { parseSpecialCharSequences: false });
|
||||
ndv.getters.codeEditorFullscreen().should('contain.text', '{ "key": "value" }');
|
||||
cy.wait(200);
|
||||
ndv.getters.codeEditorDialog().find('.el-dialog__close').click();
|
||||
ndv.getters
|
||||
.parameterInput('jsonOutput')
|
||||
.get('.cm-content')
|
||||
.should('contain.text', '{ "key": "value" }');
|
||||
});
|
||||
|
||||
it('should not retrieve remote options when a parameter value changes', () => {
|
||||
|
|
|
@ -200,7 +200,14 @@ describe('Workflow Actions', () => {
|
|||
WorkflowPage.getters.nodeConnections().should('have.length', 2);
|
||||
// Check if all nodes have names
|
||||
WorkflowPage.getters.canvasNodes().each((node) => {
|
||||
cy.wrap(node).should('have.attr', 'data-name');
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
cy.wrap(node).should('have.attr', 'data-name');
|
||||
},
|
||||
() => {
|
||||
cy.wrap(node).should('have.attr', 'data-node-name');
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -105,7 +105,7 @@ describe('Expression editor modal', () => {
|
|||
// Run workflow
|
||||
cy.get('body').type('{esc}');
|
||||
ndv.actions.close();
|
||||
WorkflowPage.actions.executeNode('No Operation');
|
||||
WorkflowPage.actions.executeNode('No Operation, do nothing', { anchor: 'topLeft' });
|
||||
WorkflowPage.actions.openNode('Hacker News');
|
||||
WorkflowPage.actions.openExpressionEditorModal();
|
||||
|
||||
|
|
77
cypress/fixtures/Test_workflow_chat_partial_execution.json
Normal file
77
cypress/fixtures/Test_workflow_chat_partial_execution.json
Normal file
|
@ -0,0 +1,77 @@
|
|||
{
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"options": {}
|
||||
},
|
||||
"id": "535fd3dd-e78f-4ffa-a085-79723fc81b38",
|
||||
"name": "When chat message received",
|
||||
"type": "@n8n/n8n-nodes-langchain.chatTrigger",
|
||||
"typeVersion": 1.1,
|
||||
"position": [
|
||||
320,
|
||||
-380
|
||||
],
|
||||
"webhookId": "4fb58136-3481-494a-a30f-d9e064dac186"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "raw",
|
||||
"jsonOutput": "{\n \"this_my_field_1\": \"value\",\n \"this_my_field_2\": 1\n}\n",
|
||||
"options": {}
|
||||
},
|
||||
"id": "78201ec2-6def-40b7-85e5-97b580d7f642",
|
||||
"name": "Node 1",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
580,
|
||||
-380
|
||||
]
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"mode": "raw",
|
||||
"jsonOutput": "{\n \"this_my_field_3\": \"value\",\n \"this_my_field_4\": 1\n}\n",
|
||||
"options": {}
|
||||
},
|
||||
"id": "1cfca06d-3ec3-427f-89f7-1ef321e025ff",
|
||||
"name": "Node 2",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.4,
|
||||
"position": [
|
||||
780,
|
||||
-380
|
||||
]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"When chat message received": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Node 1",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
"Node 1": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Node 2",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"pinData": {},
|
||||
"meta": {
|
||||
"templateCredsSetupCompleted": true,
|
||||
"instanceId": "178ef8a5109fc76c716d40bcadb720c455319f7b7a3fd5a39e4f336a091f524a"
|
||||
}
|
||||
}
|
|
@ -6,7 +6,7 @@
|
|||
"cypress:install": "cypress install",
|
||||
"test:e2e:ui": "scripts/run-e2e.js ui",
|
||||
"test:e2e:dev": "scripts/run-e2e.js dev",
|
||||
"test:e2e:dev:v2": "scripts/run-e2e.js dev:v2",
|
||||
"test:e2e:dev:v1": "scripts/run-e2e.js dev:v1",
|
||||
"test:e2e:all": "scripts/run-e2e.js all",
|
||||
"format": "biome format --write .",
|
||||
"format:check": "biome ci .",
|
||||
|
|
|
@ -68,7 +68,10 @@ export class VariablesPage extends BasePage {
|
|||
},
|
||||
setRowValue: (row: Chainable<JQuery<HTMLElement>>, field: 'key' | 'value', value: string) => {
|
||||
row.within(() => {
|
||||
cy.getByTestId(`variable-row-${field}-input`).type('{selectAll}{del}').type(value);
|
||||
cy.getByTestId(`variable-row-${field}-input`)
|
||||
.find('input, textarea')
|
||||
.type('{selectAll}{del}')
|
||||
.type(value);
|
||||
});
|
||||
},
|
||||
cancelRowEditing: (row: Chainable<JQuery<HTMLElement>>) => {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { BasePage } from './base';
|
||||
import { NodeCreator } from './features/node-creator';
|
||||
import { clickContextMenuAction, getCanvasPane, openContextMenu } from '../composables/workflow';
|
||||
import { META_KEY } from '../constants';
|
||||
import type { OpenContextMenuOptions } from '../types';
|
||||
import { getVisibleSelect } from '../utils';
|
||||
|
@ -38,15 +39,7 @@ export class WorkflowPage extends BasePage {
|
|||
nodeCreatorSearchBar: () => cy.getByTestId('node-creator-search-bar'),
|
||||
nodeCreatorPlusButton: () => cy.getByTestId('node-creator-plus-button'),
|
||||
canvasPlusButton: () => cy.getByTestId('canvas-plus-button'),
|
||||
canvasNodes: () =>
|
||||
cy.ifCanvasVersion(
|
||||
() => cy.getByTestId('canvas-node'),
|
||||
() =>
|
||||
cy
|
||||
.getByTestId('canvas-node')
|
||||
.not('[data-node-type="n8n-nodes-internal.addNodes"]')
|
||||
.not('[data-node-type="n8n-nodes-base.stickyNote"]'),
|
||||
),
|
||||
canvasNodes: () => cy.getByTestId('canvas-node'),
|
||||
canvasNodeByName: (nodeName: string) =>
|
||||
this.getters.canvasNodes().filter(`:contains(${nodeName})`),
|
||||
nodeIssuesByName: (nodeName: string) =>
|
||||
|
@ -103,14 +96,14 @@ export class WorkflowPage extends BasePage {
|
|||
nodeConnections: () =>
|
||||
cy.ifCanvasVersion(
|
||||
() => cy.get('.jtk-connector'),
|
||||
() => cy.getByTestId('edge-label'),
|
||||
() => cy.getByTestId('edge'),
|
||||
),
|
||||
zoomToFitButton: () => cy.getByTestId('zoom-to-fit'),
|
||||
nodeEndpoints: () => cy.get('.jtk-endpoint-connected'),
|
||||
disabledNodes: () =>
|
||||
cy.ifCanvasVersion(
|
||||
() => cy.get('.node-box.disabled'),
|
||||
() => cy.get('[data-test-id*="node"][class*="disabled"]'),
|
||||
() => cy.get('[data-canvas-node-render-type][class*="disabled"]'),
|
||||
),
|
||||
selectedNodes: () =>
|
||||
cy.ifCanvasVersion(
|
||||
|
@ -189,7 +182,7 @@ export class WorkflowPage extends BasePage {
|
|||
),
|
||||
() =>
|
||||
cy.get(
|
||||
`[data-test-id="edge-label"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"]`,
|
||||
`[data-test-id="edge"][data-source-node-name="${sourceNodeName}"][data-target-node-name="${targetNodeName}"]`,
|
||||
),
|
||||
),
|
||||
getConnectionActionsBetweenNodes: (sourceNodeName: string, targetNodeName: string) =>
|
||||
|
@ -288,71 +281,77 @@ export class WorkflowPage extends BasePage {
|
|||
nodeTypeName?: string,
|
||||
{ method = 'right-click', anchor = 'center' }: OpenContextMenuOptions = {},
|
||||
) => {
|
||||
const target = nodeTypeName
|
||||
? this.getters.canvasNodeByName(nodeTypeName)
|
||||
: this.getters.nodeViewBackground();
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
const target = nodeTypeName
|
||||
? this.getters.canvasNodeByName(nodeTypeName)
|
||||
: this.getters.nodeViewBackground();
|
||||
|
||||
if (method === 'right-click') {
|
||||
target.rightclick(nodeTypeName ? anchor : 'topLeft', { force: true });
|
||||
} else {
|
||||
target.realHover();
|
||||
target.find('[data-test-id="overflow-node-button"]').click({ force: true });
|
||||
}
|
||||
if (method === 'right-click') {
|
||||
target.rightclick(nodeTypeName ? anchor : 'topLeft', { force: true });
|
||||
} else {
|
||||
target.realHover();
|
||||
target.find('[data-test-id="overflow-node-button"]').click({ force: true });
|
||||
}
|
||||
},
|
||||
() => {
|
||||
openContextMenu(nodeTypeName, { method, anchor });
|
||||
},
|
||||
);
|
||||
},
|
||||
openNode: (nodeTypeName: string) => {
|
||||
this.getters.canvasNodeByName(nodeTypeName).first().dblclick();
|
||||
},
|
||||
duplicateNode: (nodeTypeName: string) => {
|
||||
this.actions.openContextMenu(nodeTypeName);
|
||||
this.actions.contextMenuAction('duplicate');
|
||||
clickContextMenuAction('duplicate');
|
||||
},
|
||||
deleteNodeFromContextMenu: (nodeTypeName: string) => {
|
||||
this.actions.openContextMenu(nodeTypeName);
|
||||
this.actions.contextMenuAction('delete');
|
||||
clickContextMenuAction('delete');
|
||||
},
|
||||
executeNode: (nodeTypeName: string, options?: OpenContextMenuOptions) => {
|
||||
this.actions.openContextMenu(nodeTypeName, options);
|
||||
this.actions.contextMenuAction('execute');
|
||||
clickContextMenuAction('execute');
|
||||
},
|
||||
addStickyFromContextMenu: () => {
|
||||
this.actions.openContextMenu();
|
||||
this.actions.contextMenuAction('add_sticky');
|
||||
clickContextMenuAction('add_sticky');
|
||||
},
|
||||
renameNode: (nodeTypeName: string) => {
|
||||
this.actions.openContextMenu(nodeTypeName);
|
||||
this.actions.contextMenuAction('rename');
|
||||
clickContextMenuAction('rename');
|
||||
},
|
||||
copyNode: (nodeTypeName: string) => {
|
||||
this.actions.openContextMenu(nodeTypeName);
|
||||
this.actions.contextMenuAction('copy');
|
||||
clickContextMenuAction('copy');
|
||||
},
|
||||
contextMenuAction: (action: string) => {
|
||||
this.getters.contextMenuAction(action).click();
|
||||
},
|
||||
disableNode: (nodeTypeName: string) => {
|
||||
this.actions.openContextMenu(nodeTypeName);
|
||||
this.actions.contextMenuAction('toggle_activation');
|
||||
clickContextMenuAction('toggle_activation');
|
||||
},
|
||||
pinNode: (nodeTypeName: string) => {
|
||||
this.actions.openContextMenu(nodeTypeName);
|
||||
this.actions.contextMenuAction('toggle_pin');
|
||||
clickContextMenuAction('toggle_pin');
|
||||
},
|
||||
openNodeFromContextMenu: (nodeTypeName: string) => {
|
||||
this.actions.openContextMenu(nodeTypeName, { method: 'overflow-button' });
|
||||
this.actions.contextMenuAction('open');
|
||||
clickContextMenuAction('open');
|
||||
},
|
||||
selectAllFromContextMenu: () => {
|
||||
this.actions.openContextMenu();
|
||||
this.actions.contextMenuAction('select_all');
|
||||
clickContextMenuAction('select_all');
|
||||
},
|
||||
deselectAll: () => {
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
this.actions.openContextMenu();
|
||||
this.actions.contextMenuAction('deselect_all');
|
||||
clickContextMenuAction('deselect_all');
|
||||
},
|
||||
// rightclick doesn't work with vueFlow canvas
|
||||
() => this.getters.nodeViewBackground().click('topLeft'),
|
||||
() => getCanvasPane().click('topLeft'),
|
||||
);
|
||||
},
|
||||
openExpressionEditorModal: () => {
|
||||
|
@ -431,7 +430,7 @@ export class WorkflowPage extends BasePage {
|
|||
pinchToZoom: (steps: number, mode: 'zoomIn' | 'zoomOut' = 'zoomIn') => {
|
||||
cy.window().then((win) => {
|
||||
// Pinch-to-zoom simulates a 'wheel' event with ctrlKey: true (same as zooming by scrolling)
|
||||
this.getters.nodeView().trigger('wheel', {
|
||||
getCanvasPane().trigger('wheel', {
|
||||
force: true,
|
||||
bubbles: true,
|
||||
ctrlKey: true,
|
||||
|
|
|
@ -45,19 +45,23 @@ switch (scenario) {
|
|||
startCommand: 'start',
|
||||
url: 'http://localhost:5678/favicon.ico',
|
||||
testCommand: 'cypress open',
|
||||
customEnv: {
|
||||
CYPRESS_NODE_VIEW_VERSION: 2,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'dev':
|
||||
case 'dev:v1':
|
||||
runTests({
|
||||
startCommand: 'develop',
|
||||
url: 'http://localhost:8080/favicon.ico',
|
||||
testCommand: 'cypress open',
|
||||
customEnv: {
|
||||
CYPRESS_NODE_VIEW_VERSION: 1,
|
||||
CYPRESS_BASE_URL: 'http://localhost:8080',
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'dev:v2':
|
||||
case 'dev':
|
||||
runTests({
|
||||
startCommand: 'develop',
|
||||
url: 'http://localhost:8080/favicon.ico',
|
||||
|
@ -76,6 +80,9 @@ switch (scenario) {
|
|||
startCommand: 'start',
|
||||
url: 'http://localhost:5678/favicon.ico',
|
||||
testCommand: `cypress run --headless ${specParam}`,
|
||||
customEnv: {
|
||||
CYPRESS_NODE_VIEW_VERSION: 2,
|
||||
},
|
||||
});
|
||||
break;
|
||||
default:
|
||||
|
|
|
@ -77,7 +77,7 @@ Cypress.Commands.add('signin', ({ email, password }) => {
|
|||
|
||||
// @TODO Remove this once the switcher is removed
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('NodeView.migrated', 'true');
|
||||
win.localStorage.setItem('NodeView.migrated.release', 'true');
|
||||
win.localStorage.setItem('NodeView.switcher.discovered.beta', 'true');
|
||||
|
||||
const nodeViewVersion = Cypress.env('NODE_VIEW_VERSION');
|
||||
|
@ -172,6 +172,7 @@ Cypress.Commands.add('drag', (selector, pos, options) => {
|
|||
};
|
||||
if (options?.realMouse) {
|
||||
element.realMouseDown();
|
||||
element.realMouseMove(0, 0);
|
||||
element.realMouseMove(newPosition.x, newPosition.y);
|
||||
element.realMouseUp();
|
||||
} else {
|
||||
|
@ -218,8 +219,15 @@ Cypress.Commands.add('draganddrop', (draggableSelector, droppableSelector, optio
|
|||
const pageY = coords.top + coords.height / 2;
|
||||
|
||||
if (draggableSelector) {
|
||||
// We can't use realMouseDown here because it hangs headless run
|
||||
cy.get(draggableSelector).trigger('mousedown');
|
||||
cy.ifCanvasVersion(
|
||||
() => {
|
||||
// We can't use realMouseDown here because it hangs headless run
|
||||
cy.get(draggableSelector).trigger('mousedown');
|
||||
},
|
||||
() => {
|
||||
cy.get(draggableSelector).realMouseDown();
|
||||
},
|
||||
);
|
||||
}
|
||||
// We don't chain these commands to make sure cy.get is re-trying correctly
|
||||
cy.get(droppableSelector).realMouseMove(0, 0);
|
||||
|
|
|
@ -38,7 +38,21 @@ beforeEach(() => {
|
|||
data: { status: 'success', message: 'Tested successfully' },
|
||||
}).as('credentialTest');
|
||||
|
||||
cy.intercept('POST', '/rest/license/renew', {});
|
||||
cy.intercept('POST', '/rest/license/renew', {
|
||||
data: {
|
||||
usage: {
|
||||
activeWorkflowTriggers: {
|
||||
limit: -1,
|
||||
value: 0,
|
||||
warningThreshold: 0.8,
|
||||
},
|
||||
},
|
||||
license: {
|
||||
planId: '',
|
||||
planName: 'Community',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
cy.intercept({ pathname: '/api/health' }, { status: 'OK' }).as('healthCheck');
|
||||
cy.intercept({ pathname: '/api/versions/*' }, [
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
"command": "/usr/local/bin/node",
|
||||
"args": [
|
||||
"--disallow-code-generation-from-strings",
|
||||
"--disable-proto=delete",
|
||||
"/usr/local/lib/node_modules/n8n/node_modules/@n8n/task-runner/dist/start.js"
|
||||
],
|
||||
"allowed-env": [
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "1.74.0",
|
||||
"version": "1.76.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20.15",
|
||||
|
@ -21,7 +21,7 @@
|
|||
"dev:fe": "run-p start \"dev:fe:editor --filter=n8n-design-system\"",
|
||||
"dev:fe:editor": "turbo run dev --parallel --env-mode=loose --filter=n8n-editor-ui",
|
||||
"dev:e2e": "cd cypress && pnpm run test:e2e:dev",
|
||||
"dev:e2e:v2": "cd cypress && pnpm run test:e2e:dev:v2",
|
||||
"dev:e2e:v1": "cd cypress && pnpm run test:e2e:dev:v1",
|
||||
"dev:e2e:server": "run-p start dev:fe:editor",
|
||||
"clean": "turbo run clean --parallel",
|
||||
"reset": "node scripts/ensure-zx.mjs && zx scripts/reset.mjs",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "0.12.0",
|
||||
"version": "0.13.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
@ -52,6 +52,17 @@ type NodeExecuteAfter = {
|
|||
executionId: string;
|
||||
nodeName: string;
|
||||
data: ITaskData;
|
||||
|
||||
/**
|
||||
* When a worker relays updates about a manual execution to main, if the
|
||||
* payload size is above a limit, we send only a placeholder to the client.
|
||||
* Later we fetch the entire execution data and fill in any placeholders.
|
||||
*
|
||||
* When sending a placheolder, we also send the number of output items, so
|
||||
* the client knows ahead of time how many items are there, to prevent the
|
||||
* items count from jumping up when the execution finishes.
|
||||
*/
|
||||
itemCount?: number;
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -27,7 +27,7 @@ docker run ghcr.io/n8n-io/n8n-benchmark:latest run \
|
|||
--n8nUserPassword=InstanceOwnerPassword \
|
||||
--vus=5 \
|
||||
--duration=1m \
|
||||
--scenarioFilter SingleWebhook
|
||||
--scenarioFilter=single-webhook
|
||||
```
|
||||
|
||||
### Using custom scenarios with the Docker image
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/n8n-benchmark",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"description": "Cli for running benchmark tests for n8n",
|
||||
"main": "dist/index",
|
||||
"scripts": {
|
||||
|
|
|
@ -32,6 +32,9 @@ services:
|
|||
- DB_TYPE=postgresdb
|
||||
- DB_POSTGRESDB_HOST=postgres
|
||||
- DB_POSTGRESDB_PASSWORD=password
|
||||
# Task Runner config
|
||||
- N8N_RUNNERS_ENABLED=true
|
||||
- N8N_RUNNERS_MODE=internal
|
||||
ports:
|
||||
- 5678:5678
|
||||
volumes:
|
||||
|
|
|
@ -50,6 +50,9 @@ services:
|
|||
- DB_TYPE=postgresdb
|
||||
- DB_POSTGRESDB_HOST=postgres
|
||||
- DB_POSTGRESDB_PASSWORD=password
|
||||
# Task Runner config
|
||||
- N8N_RUNNERS_ENABLED=true
|
||||
- N8N_RUNNERS_MODE=internal
|
||||
command: worker
|
||||
volumes:
|
||||
- ${RUN_DIR}/n8n-worker1:/n8n
|
||||
|
@ -82,6 +85,9 @@ services:
|
|||
- DB_TYPE=postgresdb
|
||||
- DB_POSTGRESDB_HOST=postgres
|
||||
- DB_POSTGRESDB_PASSWORD=password
|
||||
# Task Runner config
|
||||
- N8N_RUNNERS_ENABLED=true
|
||||
- N8N_RUNNERS_MODE=internal
|
||||
command: worker
|
||||
volumes:
|
||||
- ${RUN_DIR}/n8n-worker2:/n8n
|
||||
|
@ -117,6 +123,9 @@ services:
|
|||
- DB_TYPE=postgresdb
|
||||
- DB_POSTGRESDB_HOST=postgres
|
||||
- DB_POSTGRESDB_PASSWORD=password
|
||||
# Task Runner config
|
||||
- N8N_RUNNERS_ENABLED=true
|
||||
- N8N_RUNNERS_MODE=internal
|
||||
volumes:
|
||||
- ${RUN_DIR}/n8n-main2:/n8n
|
||||
depends_on:
|
||||
|
@ -154,7 +163,9 @@ services:
|
|||
- DB_TYPE=postgresdb
|
||||
- DB_POSTGRESDB_HOST=postgres
|
||||
- DB_POSTGRESDB_PASSWORD=password
|
||||
|
||||
# Task Runner config
|
||||
- N8N_RUNNERS_ENABLED=true
|
||||
- N8N_RUNNERS_MODE=internal
|
||||
volumes:
|
||||
- ${RUN_DIR}/n8n-main1:/n8n
|
||||
depends_on:
|
||||
|
|
|
@ -48,6 +48,9 @@ services:
|
|||
- DB_TYPE=postgresdb
|
||||
- DB_POSTGRESDB_HOST=postgres
|
||||
- DB_POSTGRESDB_PASSWORD=password
|
||||
# Task Runner config
|
||||
- N8N_RUNNERS_ENABLED=true
|
||||
- N8N_RUNNERS_MODE=internal
|
||||
command: worker
|
||||
volumes:
|
||||
- ${RUN_DIR}/n8n-worker1:/n8n
|
||||
|
@ -78,6 +81,9 @@ services:
|
|||
- DB_TYPE=postgresdb
|
||||
- DB_POSTGRESDB_HOST=postgres
|
||||
- DB_POSTGRESDB_PASSWORD=password
|
||||
# Task Runner config
|
||||
- N8N_RUNNERS_ENABLED=true
|
||||
- N8N_RUNNERS_MODE=internal
|
||||
command: worker
|
||||
volumes:
|
||||
- ${RUN_DIR}/n8n-worker2:/n8n
|
||||
|
@ -109,6 +115,9 @@ services:
|
|||
- DB_TYPE=postgresdb
|
||||
- DB_POSTGRESDB_HOST=postgres
|
||||
- DB_POSTGRESDB_PASSWORD=password
|
||||
# Task Runner config
|
||||
- N8N_RUNNERS_ENABLED=true
|
||||
- N8N_RUNNERS_MODE=internal
|
||||
ports:
|
||||
- 5678:5678
|
||||
volumes:
|
||||
|
|
|
@ -14,6 +14,9 @@ services:
|
|||
- N8N_USER_FOLDER=/n8n
|
||||
- DB_SQLITE_POOL_SIZE=3
|
||||
- DB_SQLITE_ENABLE_WAL=true
|
||||
# Task Runner config
|
||||
- N8N_RUNNERS_ENABLED=true
|
||||
- N8N_RUNNERS_MODE=internal
|
||||
ports:
|
||||
- 5678:5678
|
||||
volumes:
|
||||
|
|
|
@ -15,7 +15,7 @@ entity { Plaintext | Resolvable }
|
|||
|
||||
resolvableChar { unicodeChar | "}" ![}] | "\\}}" }
|
||||
|
||||
unicodeChar { $[\u0000-\u007C] | $[\u007E-\u20CF] | $[\u{1F300}-\u{1F64F}] | $[\u4E00-\u9FFF] }
|
||||
unicodeChar { $[\u0000-\u007C] | $[\u007E-\u20CF] | $[\u{1F300}-\u{1FAF8}] | $[\u4E00-\u9FFF] }
|
||||
}
|
||||
|
||||
@detectDelim
|
||||
|
|
|
@ -10,7 +10,7 @@ export const parser = LRParser.deserialize({
|
|||
skippedNodes: [0],
|
||||
repeatNodeCount: 1,
|
||||
tokenData:
|
||||
"&U~RTO#ob#o#p!h#p;'Sb;'S;=`!]<%lOb~gTQ~O#ob#o#pv#p;'Sb;'S;=`!]<%lOb~yUO#ob#p;'Sb;'S;=`!]<%l~b~Ob~~!c~!`P;=`<%lb~!hOQ~~!kVO#ob#o#p#Q#p;'Sb;'S;=`!]<%l~b~Ob~~!c~#TWO#O#Q#O#P#m#P#q#Q#q#r%Z#r$Ml#Q*5S41d#Q;(b;(c%x;(c;(d&O~#pWO#O#Q#O#P#m#P#q#Q#q#r$Y#r$Ml#Q*5S41d#Q;(b;(c%x;(c;(d&O~$]TO#q#Q#q#r$l#r;'S#Q;'S;=`%r<%lO#Q~$qWR~O#O#Q#O#P#m#P#q#Q#q#r%Z#r$Ml#Q*5S41d#Q;(b;(c%x;(c;(d&O~%^TO#q#Q#q#r%m#r;'S#Q;'S;=`%r<%lO#Q~%rOR~~%uP;=`<%l#Q~%{P;NQ<%l#Q~&RP;=`;JY#Q",
|
||||
"&_~RTO#ob#o#p!h#p;'Sb;'S;=`!]<%lOb~gTQ~O#ob#o#pv#p;'Sb;'S;=`!]<%lOb~yUO#ob#p;'Sb;'S;=`!]<%l~b~Ob~~!c~!`P;=`<%lb~!hOQ~~!kVO#ob#o#p#Q#p;'Sb;'S;=`!]<%l~b~Ob~~!c~#TXO#O#Q#O#P#p#P#q#Q#q#r%d#r$Ml#Q*5S41d#Q;(b;(c&R;(c;(d%{;(d;(e&X~#sXO#O#Q#O#P#p#P#q#Q#q#r$`#r$Ml#Q*5S41d#Q;(b;(c&R;(c;(d%{;(d;(e&X~$cTO#q#Q#q#r$r#r;'S#Q;'S;=`%{<%lO#Q~$wXR~O#O#Q#O#P#p#P#q#Q#q#r%d#r$Ml#Q*5S41d#Q;(b;(c&R;(c;(d%{;(d;(e&X~%gTO#q#Q#q#r%v#r;'S#Q;'S;=`%{<%lO#Q~%{OR~~&OP;=`<%l#Q~&UP;NQ<%l#Q~&[P;=`;My#Q",
|
||||
tokenizers: [0],
|
||||
topRules: { Program: [0, 1] },
|
||||
tokenPrec: 0,
|
||||
|
|
|
@ -277,3 +277,19 @@ Program(Resolvable)
|
|||
==>
|
||||
|
||||
Program(Resolvable)
|
||||
|
||||
# Resolvable with new emoji range
|
||||
|
||||
{{ '🟢' }}
|
||||
|
||||
==>
|
||||
|
||||
Program(Resolvable)
|
||||
|
||||
# Resolvable with new emoji range end of range
|
||||
|
||||
{{ '🫸' }}
|
||||
|
||||
==>
|
||||
|
||||
Program(Resolvable)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/config",
|
||||
"version": "1.24.0",
|
||||
"version": "1.26.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
@ -107,7 +107,7 @@ class MysqlConfig {
|
|||
}
|
||||
|
||||
@Config
|
||||
class SqliteConfig {
|
||||
export class SqliteConfig {
|
||||
/** SQLite database file name */
|
||||
@Env('DB_SQLITE_DATABASE')
|
||||
database: string = 'database.sqlite';
|
||||
|
|
|
@ -12,7 +12,6 @@ export class TaskRunnersConfig {
|
|||
@Env('N8N_RUNNERS_ENABLED')
|
||||
enabled: boolean = false;
|
||||
|
||||
// Defaults to true for now
|
||||
@Env('N8N_RUNNERS_MODE')
|
||||
mode: TaskRunnerMode = 'internal';
|
||||
|
||||
|
@ -23,12 +22,12 @@ export class TaskRunnersConfig {
|
|||
@Env('N8N_RUNNERS_AUTH_TOKEN')
|
||||
authToken: string = '';
|
||||
|
||||
/** IP address task runners server should listen on */
|
||||
@Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT')
|
||||
/** IP address task runners broker should listen on */
|
||||
@Env('N8N_RUNNERS_BROKER_PORT')
|
||||
port: number = 5679;
|
||||
|
||||
/** IP address task runners server should listen on */
|
||||
@Env('N8N_RUNNERS_SERVER_LISTEN_ADDRESS')
|
||||
/** IP address task runners broker should listen on */
|
||||
@Env('N8N_RUNNERS_BROKER_LISTEN_ADDRESS')
|
||||
listenAddress: string = '127.0.0.1';
|
||||
|
||||
/** Maximum size of a payload sent to the runner in bytes, Default 1G */
|
||||
|
|
|
@ -1,2 +1,7 @@
|
|||
/** @type {import('jest').Config} */
|
||||
module.exports = require('../../../jest.config');
|
||||
module.exports = {
|
||||
...require('../../../jest.config'),
|
||||
transform: {
|
||||
'^.+\\.ts$': ['ts-jest', { isolatedModules: false }],
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/di",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
17
packages/@n8n/di/src/__tests__/circular-depedency.test.ts
Normal file
17
packages/@n8n/di/src/__tests__/circular-depedency.test.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { ServiceA } from './fixtures/ServiceA';
|
||||
import { ServiceB } from './fixtures/ServiceB';
|
||||
import { Container } from '../di';
|
||||
|
||||
describe('DI Container', () => {
|
||||
describe('circular dependency', () => {
|
||||
it('should detect multilevel circular dependencies', () => {
|
||||
expect(() => Container.get(ServiceA)).toThrow(
|
||||
'[DI] Circular dependency detected in ServiceB at index 0.\nServiceA -> ServiceB',
|
||||
);
|
||||
|
||||
expect(() => Container.get(ServiceB)).toThrow(
|
||||
'[DI] Circular dependency detected in ServiceB at index 0.\nServiceB',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
8
packages/@n8n/di/src/__tests__/fixtures/ServiceA.ts
Normal file
8
packages/@n8n/di/src/__tests__/fixtures/ServiceA.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
// eslint-disable-next-line import/no-cycle
|
||||
import { ServiceB } from './ServiceB';
|
||||
import { Service } from '../../di';
|
||||
|
||||
@Service()
|
||||
export class ServiceA {
|
||||
constructor(readonly b: ServiceB) {}
|
||||
}
|
8
packages/@n8n/di/src/__tests__/fixtures/ServiceB.ts
Normal file
8
packages/@n8n/di/src/__tests__/fixtures/ServiceB.ts
Normal file
|
@ -0,0 +1,8 @@
|
|||
// eslint-disable-next-line import/no-cycle
|
||||
import { ServiceA } from './ServiceA';
|
||||
import { Service } from '../../di';
|
||||
|
||||
@Service()
|
||||
export class ServiceB {
|
||||
constructor(readonly a: ServiceA) {}
|
||||
}
|
|
@ -78,13 +78,6 @@ class ContainerClass {
|
|||
|
||||
if (metadata?.instance) return metadata.instance as T;
|
||||
|
||||
// Check for circular dependencies before proceeding with instantiation
|
||||
if (resolutionStack.includes(type)) {
|
||||
throw new DIError(
|
||||
`Circular dependency detected. ${resolutionStack.map((t) => t.name).join(' -> ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Add current type to resolution stack before resolving dependencies
|
||||
resolutionStack.push(type);
|
||||
|
||||
|
@ -96,9 +89,15 @@ class ContainerClass {
|
|||
} else {
|
||||
const paramTypes = (Reflect.getMetadata('design:paramtypes', type) ??
|
||||
[]) as Constructable[];
|
||||
const dependencies = paramTypes.map(<P>(paramType: Constructable<P>) =>
|
||||
this.get(paramType),
|
||||
);
|
||||
|
||||
const dependencies = paramTypes.map(<P>(paramType: Constructable<P>, index: number) => {
|
||||
if (paramType === undefined) {
|
||||
throw new DIError(
|
||||
`Circular dependency detected in ${type.name} at index ${index}.\n${resolutionStack.map((t) => t.name).join(' -> ')}\n`,
|
||||
);
|
||||
}
|
||||
return this.get(paramType);
|
||||
});
|
||||
// Create new instance with resolved dependencies
|
||||
instance = new (type as Constructable)(...dependencies) as T;
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ const modelParameter: INodeProperties = {
|
|||
routing: {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '={{ $parameter.options?.baseURL?.split("/").slice(-1).pop() || "v1" }}/models',
|
||||
url: '={{ $parameter.options?.baseURL?.split("/").slice(-1).pop() || $credentials?.url?.split("/").slice(-1).pop() || "v1" }}/models',
|
||||
},
|
||||
output: {
|
||||
postReceive: [
|
||||
|
|
|
@ -11,18 +11,25 @@ import {
|
|||
|
||||
import { getConnectionHintNoticeField } from '@utils/sharedFields';
|
||||
|
||||
import { searchModels } from './methods/loadModels';
|
||||
import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling';
|
||||
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
|
||||
import { N8nLlmTracing } from '../N8nLlmTracing';
|
||||
|
||||
export class LmChatOpenAi implements INodeType {
|
||||
methods = {
|
||||
listSearch: {
|
||||
searchModels,
|
||||
},
|
||||
};
|
||||
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'OpenAI Chat Model',
|
||||
// eslint-disable-next-line n8n-nodes-base/node-class-description-name-miscased
|
||||
name: 'lmChatOpenAi',
|
||||
icon: { light: 'file:openAiLight.svg', dark: 'file:openAiLight.dark.svg' },
|
||||
group: ['transform'],
|
||||
version: [1, 1.1],
|
||||
version: [1, 1.1, 1.2],
|
||||
description: 'For advanced usage with an AI chain',
|
||||
defaults: {
|
||||
name: 'OpenAI Chat Model',
|
||||
|
@ -130,6 +137,42 @@ export class LmChatOpenAi implements INodeType {
|
|||
},
|
||||
},
|
||||
default: 'gpt-4o-mini',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [{ _cnd: { gte: 1.2 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Model',
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: 'gpt-4o-mini' },
|
||||
required: true,
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
placeholder: 'Select a model...',
|
||||
typeOptions: {
|
||||
searchListMethod: 'searchModels',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
placeholder: 'gpt-4o-mini',
|
||||
},
|
||||
],
|
||||
description: 'The model. Choose from the list, or specify an ID.',
|
||||
displayOptions: {
|
||||
hide: {
|
||||
'@version': [{ _cnd: { lte: 1.1 } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName:
|
||||
|
@ -251,7 +294,12 @@ export class LmChatOpenAi implements INodeType {
|
|||
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
|
||||
const credentials = await this.getCredentials('openAiApi');
|
||||
|
||||
const modelName = this.getNodeParameter('model', itemIndex) as string;
|
||||
const version = this.getNode().typeVersion;
|
||||
const modelName =
|
||||
version >= 1.2
|
||||
? (this.getNodeParameter('model.value', itemIndex) as string)
|
||||
: (this.getNodeParameter('model', itemIndex) as string);
|
||||
|
||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||
baseURL?: string;
|
||||
frequencyPenalty?: number;
|
||||
|
|
|
@ -0,0 +1,112 @@
|
|||
import type { ILoadOptionsFunctions } from 'n8n-workflow';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
import { searchModels } from '../loadModels';
|
||||
|
||||
jest.mock('openai');
|
||||
|
||||
describe('searchModels', () => {
|
||||
let mockContext: jest.Mocked<ILoadOptionsFunctions>;
|
||||
let mockOpenAI: jest.Mocked<typeof OpenAI>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = {
|
||||
getCredentials: jest.fn().mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
}),
|
||||
getNodeParameter: jest.fn().mockReturnValue(''),
|
||||
} as unknown as jest.Mocked<ILoadOptionsFunctions>;
|
||||
|
||||
// Setup OpenAI mock with required properties
|
||||
const mockOpenAIInstance = {
|
||||
apiKey: 'test-api-key',
|
||||
organization: null,
|
||||
project: null,
|
||||
_options: {},
|
||||
models: {
|
||||
list: jest.fn().mockResolvedValue({
|
||||
data: [
|
||||
{ id: 'gpt-4' },
|
||||
{ id: 'gpt-3.5-turbo' },
|
||||
{ id: 'gpt-3.5-turbo-instruct' },
|
||||
{ id: 'ft:gpt-3.5-turbo' },
|
||||
{ id: 'o1-model' },
|
||||
{ id: 'other-model' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
} as unknown as OpenAI;
|
||||
|
||||
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => mockOpenAIInstance);
|
||||
|
||||
mockOpenAI = OpenAI as jest.Mocked<typeof OpenAI>;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return filtered models if custom API endpoint is not provided', async () => {
|
||||
const result = await searchModels.call(mockContext);
|
||||
|
||||
expect(mockOpenAI).toHaveBeenCalledWith({
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: 'test-api-key',
|
||||
});
|
||||
expect(result.results).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should initialize OpenAI with correct credentials', async () => {
|
||||
mockContext.getCredentials.mockResolvedValueOnce({
|
||||
apiKey: 'test-api-key',
|
||||
url: 'https://test-url.com',
|
||||
});
|
||||
await searchModels.call(mockContext);
|
||||
|
||||
expect(mockOpenAI).toHaveBeenCalledWith({
|
||||
baseURL: 'https://test-url.com',
|
||||
apiKey: 'test-api-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use default OpenAI URL if no custom URL provided', async () => {
|
||||
mockContext.getCredentials = jest.fn().mockResolvedValue({
|
||||
apiKey: 'test-api-key',
|
||||
});
|
||||
|
||||
await searchModels.call(mockContext);
|
||||
|
||||
expect(mockOpenAI).toHaveBeenCalledWith({
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
apiKey: 'test-api-key',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include all models for custom API endpoints', async () => {
|
||||
mockContext.getNodeParameter = jest.fn().mockReturnValue('https://custom-api.com');
|
||||
|
||||
const result = await searchModels.call(mockContext);
|
||||
|
||||
expect(result.results).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('should filter models based on search term', async () => {
|
||||
const result = await searchModels.call(mockContext, 'gpt');
|
||||
|
||||
expect(result.results).toEqual([
|
||||
{ name: 'gpt-4', value: 'gpt-4' },
|
||||
{ name: 'gpt-3.5-turbo', value: 'gpt-3.5-turbo' },
|
||||
{ name: 'ft:gpt-3.5-turbo', value: 'ft:gpt-3.5-turbo' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle case-insensitive search', async () => {
|
||||
const result = await searchModels.call(mockContext, 'GPT');
|
||||
|
||||
expect(result.results).toEqual([
|
||||
{ name: 'gpt-4', value: 'gpt-4' },
|
||||
{ name: 'gpt-3.5-turbo', value: 'gpt-3.5-turbo' },
|
||||
{ name: 'ft:gpt-3.5-turbo', value: 'ft:gpt-3.5-turbo' },
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,37 @@
|
|||
import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
|
||||
import OpenAI from 'openai';
|
||||
|
||||
export async function searchModels(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const credentials = await this.getCredentials('openAiApi');
|
||||
const baseURL =
|
||||
(this.getNodeParameter('options.baseURL', '') as string) ||
|
||||
(credentials.url as string) ||
|
||||
'https://api.openai.com/v1';
|
||||
|
||||
const openai = new OpenAI({ baseURL, apiKey: credentials.apiKey as string });
|
||||
const { data: models = [] } = await openai.models.list();
|
||||
|
||||
const filteredModels = models.filter((model: { id: string }) => {
|
||||
const isValidModel =
|
||||
(baseURL && !baseURL.includes('api.openai.com')) ||
|
||||
model.id.startsWith('ft:') ||
|
||||
model.id.startsWith('o1') ||
|
||||
(model.id.startsWith('gpt-') && !model.id.includes('instruct'));
|
||||
|
||||
if (!filter) return isValidModel;
|
||||
|
||||
return isValidModel && model.id.toLowerCase().includes(filter.toLowerCase());
|
||||
});
|
||||
|
||||
const results = {
|
||||
results: filteredModels.map((model: { id: string }) => ({
|
||||
name: model.id,
|
||||
value: model.id,
|
||||
})),
|
||||
};
|
||||
|
||||
return results;
|
||||
}
|
|
@ -140,6 +140,7 @@ export class LmChatGoogleGemini implements INodeType {
|
|||
|
||||
const model = new ChatGoogleGenerativeAI({
|
||||
apiKey: credentials.apiKey as string,
|
||||
baseUrl: credentials.host as string,
|
||||
modelName,
|
||||
topK: options.topK,
|
||||
topP: options.topP,
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
/* eslint-disable n8n-nodes-base/node-dirname-against-convention */
|
||||
import { PostgresChatMessageHistory } from '@langchain/community/stores/message/postgres';
|
||||
import { BufferMemory, BufferWindowMemory } from 'langchain/memory';
|
||||
import { configurePostgres } from 'n8n-nodes-base/dist/nodes/Postgres/transport';
|
||||
import type { PostgresNodeCredentials } from 'n8n-nodes-base/dist/nodes/Postgres/v2/helpers/interfaces';
|
||||
import { postgresConnectionTest } from 'n8n-nodes-base/dist/nodes/Postgres/v2/methods/credentialTest';
|
||||
import { configurePostgres } from 'n8n-nodes-base/dist/nodes/Postgres/v2/transport';
|
||||
import type {
|
||||
ISupplyDataFunctions,
|
||||
INodeType,
|
||||
|
@ -115,12 +115,7 @@ export class MemoryPostgresChat implements INodeType {
|
|||
...kOptions,
|
||||
});
|
||||
|
||||
async function closeFunction() {
|
||||
void pool.end();
|
||||
}
|
||||
|
||||
return {
|
||||
closeFunction,
|
||||
response: logWrapper(memory, this),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -4,8 +4,8 @@ import {
|
|||
type PGVectorStoreArgs,
|
||||
} from '@langchain/community/vectorstores/pgvector';
|
||||
import type { EmbeddingsInterface } from '@langchain/core/embeddings';
|
||||
import { configurePostgres } from 'n8n-nodes-base/dist/nodes/Postgres/transport';
|
||||
import type { PostgresNodeCredentials } from 'n8n-nodes-base/dist/nodes/Postgres/v2/helpers/interfaces';
|
||||
import { configurePostgres } from 'n8n-nodes-base/dist/nodes/Postgres/v2/transport';
|
||||
import type { INodeProperties } from 'n8n-workflow';
|
||||
import type pg from 'pg';
|
||||
|
||||
|
|
|
@ -392,13 +392,9 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
|
|||
);
|
||||
resultData.push(...serializedDocuments);
|
||||
|
||||
try {
|
||||
await args.populateVectorStore(this, embeddings, processedDocuments, itemIndex);
|
||||
await args.populateVectorStore(this, embeddings, processedDocuments, itemIndex);
|
||||
|
||||
logAiEvent(this, 'ai-vector-store-populated');
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
logAiEvent(this, 'ai-vector-store-populated');
|
||||
}
|
||||
|
||||
return [resultData];
|
||||
|
@ -443,16 +439,12 @@ export const createVectorStoreNode = (args: VectorStoreNodeConstructorArgs) =>
|
|||
|
||||
resultData.push(...serializedDocuments);
|
||||
|
||||
try {
|
||||
// Use ids option to upsert instead of insert
|
||||
await vectorStore.addDocuments(processedDocuments, {
|
||||
ids: [documentId],
|
||||
});
|
||||
// Use ids option to upsert instead of insert
|
||||
await vectorStore.addDocuments(processedDocuments, {
|
||||
ids: [documentId],
|
||||
});
|
||||
|
||||
logAiEvent(this, 'ai-vector-store-updated');
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
logAiEvent(this, 'ai-vector-store-updated');
|
||||
}
|
||||
|
||||
return [resultData];
|
||||
|
|
|
@ -76,9 +76,15 @@ export async function modelSearch(
|
|||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const credentials = await this.getCredentials<{ url: string }>('openAiApi');
|
||||
const isCustomAPI = credentials.url && !credentials.url.includes('api.openai.com');
|
||||
|
||||
return await getModelSearch(
|
||||
(model) =>
|
||||
model.id.startsWith('gpt-') || model.id.startsWith('ft:') || model.id.startsWith('o1'),
|
||||
isCustomAPI ||
|
||||
model.id.startsWith('gpt-') ||
|
||||
model.id.startsWith('ft:') ||
|
||||
model.id.startsWith('o1'),
|
||||
)(this, filter);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/n8n-nodes-langchain",
|
||||
"version": "1.74.0",
|
||||
"version": "1.76.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@ -136,17 +136,17 @@
|
|||
"@google-cloud/resource-manager": "5.3.0",
|
||||
"@google/generative-ai": "0.21.0",
|
||||
"@huggingface/inference": "2.8.0",
|
||||
"@langchain/anthropic": "0.3.8",
|
||||
"@langchain/aws": "0.1.2",
|
||||
"@langchain/cohere": "0.3.1",
|
||||
"@langchain/community": "0.3.15",
|
||||
"@langchain/anthropic": "0.3.11",
|
||||
"@langchain/aws": "0.1.3",
|
||||
"@langchain/cohere": "0.3.2",
|
||||
"@langchain/community": "0.3.24",
|
||||
"@langchain/core": "catalog:",
|
||||
"@langchain/google-genai": "0.1.4",
|
||||
"@langchain/google-vertexai": "0.1.3",
|
||||
"@langchain/groq": "0.1.2",
|
||||
"@langchain/google-genai": "0.1.6",
|
||||
"@langchain/google-vertexai": "0.1.8",
|
||||
"@langchain/groq": "0.1.3",
|
||||
"@langchain/mistralai": "0.2.0",
|
||||
"@langchain/ollama": "0.1.2",
|
||||
"@langchain/openai": "0.3.14",
|
||||
"@langchain/ollama": "0.1.4",
|
||||
"@langchain/openai": "0.3.17",
|
||||
"@langchain/pinecone": "0.1.3",
|
||||
"@langchain/qdrant": "0.1.1",
|
||||
"@langchain/redis": "0.1.0",
|
||||
|
@ -168,13 +168,13 @@
|
|||
"generate-schema": "2.6.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"jsdom": "23.0.1",
|
||||
"langchain": "0.3.6",
|
||||
"langchain": "0.3.11",
|
||||
"lodash": "catalog:",
|
||||
"mammoth": "1.7.2",
|
||||
"mime-types": "2.1.35",
|
||||
"n8n-nodes-base": "workspace:*",
|
||||
"n8n-workflow": "workspace:*",
|
||||
"openai": "4.73.1",
|
||||
"openai": "4.78.1",
|
||||
"pdf-parse": "1.1.1",
|
||||
"pg": "8.12.0",
|
||||
"redis": "4.6.12",
|
||||
|
|
|
@ -3,19 +3,19 @@
|
|||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^3.2.2",
|
||||
"@storybook/addon-a11y": "^8.4.6",
|
||||
"@storybook/addon-actions": "^8.4.6",
|
||||
"@storybook/addon-docs": "^8.4.6",
|
||||
"@storybook/addon-essentials": "^8.4.6",
|
||||
"@storybook/addon-interactions": "^8.4.6",
|
||||
"@storybook/addon-links": "^8.4.6",
|
||||
"@storybook/addon-themes": "^8.4.6",
|
||||
"@storybook/blocks": "^8.4.6",
|
||||
"@storybook/test": "^8.4.6",
|
||||
"@storybook/vue3": "^8.4.6",
|
||||
"@storybook/vue3-vite": "^8.4.6",
|
||||
"chromatic": "^11.20.0",
|
||||
"storybook": "^8.4.6"
|
||||
"@chromatic-com/storybook": "^3.2.4",
|
||||
"@storybook/addon-a11y": "^8.5.0",
|
||||
"@storybook/addon-actions": "^8.5.0",
|
||||
"@storybook/addon-docs": "^8.5.0",
|
||||
"@storybook/addon-essentials": "^8.5.0",
|
||||
"@storybook/addon-interactions": "^8.5.0",
|
||||
"@storybook/addon-links": "^8.5.0",
|
||||
"@storybook/addon-themes": "^8.5.0",
|
||||
"@storybook/blocks": "^8.5.0",
|
||||
"@storybook/test": "^8.5.0",
|
||||
"@storybook/vue3": "^8.5.0",
|
||||
"@storybook/vue3-vite": "^8.5.0",
|
||||
"chromatic": "^11.25.0",
|
||||
"storybook": "^8.5.0"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/task-runner",
|
||||
"version": "1.12.0",
|
||||
"version": "1.14.0",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"start": "node dist/start.js",
|
||||
|
@ -40,13 +40,13 @@
|
|||
"acorn": "8.14.0",
|
||||
"acorn-walk": "8.3.4",
|
||||
"lodash": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
"n8n-core": "workspace:*",
|
||||
"n8n-workflow": "workspace:*",
|
||||
"nanoid": "catalog:",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "catalog:",
|
||||
"luxon": "catalog:"
|
||||
"@types/lodash": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { DateTime } from 'luxon';
|
||||
import { DateTime, Duration, Interval } from 'luxon';
|
||||
import type { IBinaryData } from 'n8n-workflow';
|
||||
import { setGlobalState, type CodeExecutionMode, type IDataObject } from 'n8n-workflow';
|
||||
import fs from 'node:fs';
|
||||
|
@ -1342,4 +1342,98 @@ describe('JsTaskRunner', () => {
|
|||
task.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe('prototype pollution prevention', () => {
|
||||
const checkPrototypeIntact = () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
expect(obj.maliciousKey).toBeUndefined();
|
||||
};
|
||||
|
||||
test('Object.setPrototypeOf should no-op for local object', async () => {
|
||||
checkPrototypeIntact();
|
||||
|
||||
const outcome = await executeForAllItems({
|
||||
code: `
|
||||
const obj = {};
|
||||
Object.setPrototypeOf(obj, { maliciousKey: 'value' });
|
||||
return [{ json: { prototypeChanged: obj.maliciousKey !== undefined } }];
|
||||
`,
|
||||
inputItems: [{ a: 1 }],
|
||||
});
|
||||
|
||||
expect(outcome.result).toEqual([wrapIntoJson({ prototypeChanged: false })]);
|
||||
checkPrototypeIntact();
|
||||
});
|
||||
|
||||
test('Reflect.setPrototypeOf should no-op for local object', async () => {
|
||||
checkPrototypeIntact();
|
||||
|
||||
const outcome = await executeForAllItems({
|
||||
code: `
|
||||
const obj = {};
|
||||
Reflect.setPrototypeOf(obj, { maliciousKey: 'value' });
|
||||
return [{ json: { prototypeChanged: obj.maliciousKey !== undefined } }];
|
||||
`,
|
||||
inputItems: [{ a: 1 }],
|
||||
});
|
||||
|
||||
expect(outcome.result).toEqual([wrapIntoJson({ prototypeChanged: false })]);
|
||||
checkPrototypeIntact();
|
||||
});
|
||||
|
||||
test('Object.setPrototypeOf should no-op for incoming object', async () => {
|
||||
checkPrototypeIntact();
|
||||
|
||||
const outcome = await executeForAllItems({
|
||||
code: `
|
||||
const obj = $input.first();
|
||||
Object.setPrototypeOf(obj, { maliciousKey: 'value' });
|
||||
return [{ json: { prototypeChanged: obj.maliciousKey !== undefined } }];
|
||||
`,
|
||||
inputItems: [{ a: 1 }],
|
||||
});
|
||||
|
||||
expect(outcome.result).toEqual([wrapIntoJson({ prototypeChanged: false })]);
|
||||
checkPrototypeIntact();
|
||||
});
|
||||
|
||||
test('Reflect.setPrototypeOf should no-op for incoming object', async () => {
|
||||
checkPrototypeIntact();
|
||||
|
||||
const outcome = await executeForAllItems({
|
||||
code: `
|
||||
const obj = $input.first();
|
||||
Reflect.setPrototypeOf(obj, { maliciousKey: 'value' });
|
||||
return [{ json: { prototypeChanged: obj.maliciousKey !== undefined } }];
|
||||
`,
|
||||
inputItems: [{ a: 1 }],
|
||||
});
|
||||
|
||||
expect(outcome.result).toEqual([wrapIntoJson({ prototypeChanged: false })]);
|
||||
checkPrototypeIntact();
|
||||
});
|
||||
|
||||
test('should freeze luxon prototypes', async () => {
|
||||
const outcome = await executeForAllItems({
|
||||
code: `
|
||||
[DateTime, Interval, Duration]
|
||||
.forEach(constructor => {
|
||||
constructor.prototype.maliciousKey = 'value';
|
||||
});
|
||||
|
||||
return []
|
||||
`,
|
||||
inputItems: [{ a: 1 }],
|
||||
});
|
||||
|
||||
expect(outcome.result).toEqual([]);
|
||||
|
||||
// @ts-expect-error Non-existing property
|
||||
expect(DateTime.now().maliciousKey).toBeUndefined();
|
||||
// @ts-expect-error Non-existing property
|
||||
expect(Interval.fromISO('P1Y2M10DT2H30M').maliciousKey).toBeUndefined();
|
||||
// @ts-expect-error Non-existing property
|
||||
expect(Duration.fromObject({ hours: 1 }).maliciousKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
import { ApplicationError } from 'n8n-workflow';
|
||||
|
||||
import { ExecutionError } from '@/js-task-runner/errors/execution-error';
|
||||
|
||||
import { createRequireResolver, type RequireResolverOpts } from '../require-resolver';
|
||||
|
||||
describe('require resolver', () => {
|
||||
let defaultOpts: RequireResolverOpts;
|
||||
|
||||
beforeEach(() => {
|
||||
defaultOpts = {
|
||||
allowedBuiltInModules: new Set(['path', 'fs']),
|
||||
allowedExternalModules: new Set(['lodash']),
|
||||
};
|
||||
});
|
||||
|
||||
describe('built-in modules', () => {
|
||||
it('should allow requiring whitelisted built-in modules', () => {
|
||||
const resolver = createRequireResolver(defaultOpts);
|
||||
expect(() => resolver('path')).not.toThrow();
|
||||
expect(() => resolver('fs')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when requiring non-whitelisted built-in modules', () => {
|
||||
const resolver = createRequireResolver(defaultOpts);
|
||||
expect(() => resolver('crypto')).toThrow(ExecutionError);
|
||||
});
|
||||
|
||||
it('should allow all built-in modules when allowedBuiltInModules is "*"', () => {
|
||||
const resolver = createRequireResolver({
|
||||
...defaultOpts,
|
||||
allowedBuiltInModules: '*',
|
||||
});
|
||||
|
||||
expect(() => resolver('path')).not.toThrow();
|
||||
expect(() => resolver('crypto')).not.toThrow();
|
||||
expect(() => resolver('fs')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('external modules', () => {
|
||||
it('should allow requiring whitelisted external modules', () => {
|
||||
const resolver = createRequireResolver(defaultOpts);
|
||||
expect(() => resolver('lodash')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when requiring non-whitelisted external modules', () => {
|
||||
const resolver = createRequireResolver(defaultOpts);
|
||||
expect(() => resolver('express')).toThrow(
|
||||
new ExecutionError(new ApplicationError("Cannot find module 'express'")),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow all external modules when allowedExternalModules is "*"', () => {
|
||||
const resolver = createRequireResolver({
|
||||
...defaultOpts,
|
||||
allowedExternalModules: '*',
|
||||
});
|
||||
|
||||
expect(() => resolver('lodash')).not.toThrow();
|
||||
expect(() => resolver('express')).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should wrap ApplicationError in ExecutionError', () => {
|
||||
const resolver = createRequireResolver(defaultOpts);
|
||||
expect(() => resolver('non-existent-module')).toThrow(ExecutionError);
|
||||
});
|
||||
|
||||
it('should include the module name in the error message', () => {
|
||||
const resolver = createRequireResolver(defaultOpts);
|
||||
expect(() => resolver('non-existent-module')).toThrow(
|
||||
"Cannot find module 'non-existent-module'",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,6 +1,7 @@
|
|||
import set from 'lodash/set';
|
||||
import { DateTime, Duration, Interval } from 'luxon';
|
||||
import { getAdditionalKeys } from 'n8n-core';
|
||||
import { WorkflowDataProxy, Workflow, ObservableObject } from 'n8n-workflow';
|
||||
import { WorkflowDataProxy, Workflow, ObservableObject, Expression } from 'n8n-workflow';
|
||||
import type {
|
||||
CodeExecutionMode,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
|
@ -97,12 +98,55 @@ export class JsTaskRunner extends TaskRunner {
|
|||
const { jsRunnerConfig } = config;
|
||||
|
||||
const parseModuleAllowList = (moduleList: string) =>
|
||||
moduleList === '*' ? null : new Set(moduleList.split(',').map((x) => x.trim()));
|
||||
moduleList === '*'
|
||||
? '*'
|
||||
: new Set(
|
||||
moduleList
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter((x) => x !== ''),
|
||||
);
|
||||
|
||||
const allowedBuiltInModules = parseModuleAllowList(jsRunnerConfig.allowedBuiltInModules ?? '');
|
||||
const allowedExternalModules = parseModuleAllowList(
|
||||
jsRunnerConfig.allowedExternalModules ?? '',
|
||||
);
|
||||
|
||||
this.requireResolver = createRequireResolver({
|
||||
allowedBuiltInModules: parseModuleAllowList(jsRunnerConfig.allowedBuiltInModules ?? ''),
|
||||
allowedExternalModules: parseModuleAllowList(jsRunnerConfig.allowedExternalModules ?? ''),
|
||||
allowedBuiltInModules,
|
||||
allowedExternalModules,
|
||||
});
|
||||
|
||||
this.preventPrototypePollution(allowedExternalModules);
|
||||
}
|
||||
|
||||
private preventPrototypePollution(allowedExternalModules: Set<string> | '*') {
|
||||
if (allowedExternalModules instanceof Set) {
|
||||
// This is a workaround to enable the allowed external libraries to mutate
|
||||
// prototypes directly. For example momentjs overrides .toString() directly
|
||||
// on the Moment.prototype, which doesn't work if Object.prototype has been
|
||||
// frozen. This works as long as the overrides are done when the library is
|
||||
// imported.
|
||||
for (const module of allowedExternalModules) {
|
||||
require(module);
|
||||
}
|
||||
}
|
||||
|
||||
// Freeze globals, except for Jest
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
Object.getOwnPropertyNames(globalThis)
|
||||
// @ts-expect-error globalThis does not have string in index signature
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
|
||||
.map((name) => globalThis[name])
|
||||
.filter((value) => typeof value === 'function')
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
|
||||
.forEach((fn) => Object.freeze(fn.prototype));
|
||||
}
|
||||
|
||||
// Freeze internal classes
|
||||
[Workflow, Expression, WorkflowDataProxy, DateTime, Interval, Duration]
|
||||
.map((constructor) => constructor.prototype)
|
||||
.forEach(Object.freeze);
|
||||
}
|
||||
|
||||
async executeTask(
|
||||
|
@ -203,8 +247,11 @@ export class JsTaskRunner extends TaskRunner {
|
|||
|
||||
signal.addEventListener('abort', abortHandler, { once: true });
|
||||
|
||||
const preventPrototypeManipulation =
|
||||
'Object.getPrototypeOf = () => ({}); Reflect.getPrototypeOf = () => ({}); Object.setPrototypeOf = () => false; Reflect.setPrototypeOf = () => false;';
|
||||
|
||||
const taskResult = runInContext(
|
||||
`globalThis.global = globalThis; module.exports = async function VmCodeWrapper() {${settings.code}\n}()`,
|
||||
`globalThis.global = globalThis; ${preventPrototypeManipulation}; module.exports = async function VmCodeWrapper() {${settings.code}\n}()`,
|
||||
context,
|
||||
{ timeout: this.taskTimeout * 1000 },
|
||||
) as Promise<TaskResultData['result']>;
|
||||
|
@ -461,7 +508,7 @@ export class JsTaskRunner extends TaskRunner {
|
|||
* @param dataProxy The data proxy object that provides access to built-ins
|
||||
* @param additionalProperties Additional properties to add to the context
|
||||
*/
|
||||
private buildContext(
|
||||
buildContext(
|
||||
taskId: string,
|
||||
workflow: Workflow,
|
||||
node: INode,
|
||||
|
|
|
@ -6,15 +6,15 @@ import { ExecutionError } from './errors/execution-error';
|
|||
export type RequireResolverOpts = {
|
||||
/**
|
||||
* List of built-in nodejs modules that are allowed to be required in the
|
||||
* execution sandbox. `null` means all are allowed.
|
||||
* execution sandbox. `"*"` means all are allowed.
|
||||
*/
|
||||
allowedBuiltInModules: Set<string> | null;
|
||||
allowedBuiltInModules: Set<string> | '*';
|
||||
|
||||
/**
|
||||
* List of external modules that are allowed to be required in the
|
||||
* execution sandbox. `null` means all are allowed.
|
||||
* execution sandbox. `"*"` means all are allowed.
|
||||
*/
|
||||
allowedExternalModules: Set<string> | null;
|
||||
allowedExternalModules: Set<string> | '*';
|
||||
};
|
||||
|
||||
export type RequireResolver = (request: string) => unknown;
|
||||
|
@ -24,8 +24,8 @@ export function createRequireResolver({
|
|||
allowedExternalModules,
|
||||
}: RequireResolverOpts) {
|
||||
return (request: string) => {
|
||||
const checkIsAllowed = (allowList: Set<string> | null, moduleName: string) => {
|
||||
return allowList ? allowList.has(moduleName) : true;
|
||||
const checkIsAllowed = (allowList: Set<string> | '*', moduleName: string) => {
|
||||
return allowList === '*' || allowList.has(moduleName);
|
||||
};
|
||||
|
||||
const isAllowed = isBuiltin(request)
|
||||
|
|
|
@ -369,6 +369,8 @@ const config = (module.exports = {
|
|||
|
||||
'n8n-local-rules/no-unused-param-in-catch-clause': 'error',
|
||||
|
||||
'n8n-local-rules/no-useless-catch-throw': 'error',
|
||||
|
||||
'n8n-local-rules/no-plain-errors': 'error',
|
||||
|
||||
// ******************************************************************
|
||||
|
|
|
@ -172,6 +172,49 @@ module.exports = {
|
|||
},
|
||||
},
|
||||
|
||||
'no-useless-catch-throw': {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Disallow `try-catch` blocks where the `catch` only contains a `throw error`.',
|
||||
recommended: 'error',
|
||||
},
|
||||
messages: {
|
||||
noUselessCatchThrow: 'Remove useless `catch` block.',
|
||||
},
|
||||
fixable: 'code',
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
CatchClause(node) {
|
||||
if (
|
||||
node.body.body.length === 1 &&
|
||||
node.body.body[0].type === 'ThrowStatement' &&
|
||||
node.body.body[0].argument.type === 'Identifier' &&
|
||||
node.body.body[0].argument.name === node.param.name
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noUselessCatchThrow',
|
||||
fix(fixer) {
|
||||
const tryStatement = node.parent;
|
||||
const tryBlock = tryStatement.block;
|
||||
const sourceCode = context.getSourceCode();
|
||||
const tryBlockText = sourceCode.getText(tryBlock);
|
||||
const tryBlockTextWithoutBraces = tryBlockText.slice(1, -1).trim();
|
||||
const indentedTryBlockText = tryBlockTextWithoutBraces
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/\t/, ''))
|
||||
.join('\n');
|
||||
return fixer.replaceText(tryStatement, indentedTryBlockText);
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
'no-skipped-tests': {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
|
|
|
@ -51,3 +51,33 @@ ruleTester.run('no-json-parse-json-stringify', rules['no-json-parse-json-stringi
|
|||
},
|
||||
],
|
||||
});
|
||||
|
||||
ruleTester.run('no-useless-catch-throw', rules['no-useless-catch-throw'], {
|
||||
valid: [
|
||||
{
|
||||
code: 'try { foo(); } catch (e) { console.error(e); }',
|
||||
},
|
||||
{
|
||||
code: 'try { foo(); } catch (e) { throw new Error("Custom error"); }',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
try {
|
||||
// Some comment
|
||||
if (foo) {
|
||||
bar();
|
||||
}
|
||||
} catch (e) {
|
||||
throw e;
|
||||
}`,
|
||||
errors: [{ messageId: 'noUselessCatchThrow' }],
|
||||
output: `
|
||||
// Some comment
|
||||
if (foo) {
|
||||
bar();
|
||||
}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n",
|
||||
"version": "1.74.0",
|
||||
"version": "1.76.0",
|
||||
"description": "n8n Workflow Automation Tool",
|
||||
"main": "dist/index",
|
||||
"types": "dist/index.d.ts",
|
||||
|
@ -96,7 +96,7 @@
|
|||
"@n8n/task-runner": "workspace:*",
|
||||
"@n8n/typeorm": "0.3.20-12",
|
||||
"@n8n_io/ai-assistant-sdk": "1.13.0",
|
||||
"@n8n_io/license-sdk": "2.13.1",
|
||||
"@n8n_io/license-sdk": "2.14.2",
|
||||
"@oclif/core": "4.0.7",
|
||||
"@rudderstack/rudder-sdk-node": "2.0.9",
|
||||
"@sentry/node": "catalog:",
|
||||
|
|
|
@ -251,6 +251,20 @@ describe('License', () => {
|
|||
|
||||
expect(LicenseManager).toHaveBeenCalledWith(expect.objectContaining(expectedRenewalSettings));
|
||||
});
|
||||
|
||||
it('when CLI command with N8N_LICENSE_AUTO_RENEW_ENABLED=true, should enable renewal', async () => {
|
||||
const globalConfig = mock<GlobalConfig>({
|
||||
license: { ...licenseConfig, autoRenewalEnabled: true },
|
||||
});
|
||||
|
||||
await new License(mockLogger(), mock(), mock(), mock(), globalConfig).init({
|
||||
isCli: true,
|
||||
});
|
||||
|
||||
expect(LicenseManager).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ autoRenewEnabled: true, renewOnInit: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reinit', () => {
|
||||
|
@ -262,7 +276,7 @@ describe('License', () => {
|
|||
|
||||
await license.reinit();
|
||||
|
||||
expect(initSpy).toHaveBeenCalledWith(true);
|
||||
expect(initSpy).toHaveBeenCalledWith({ forceRecreate: true });
|
||||
|
||||
expect(LicenseManager.prototype.reset).toHaveBeenCalled();
|
||||
expect(LicenseManager.prototype.initialize).toHaveBeenCalled();
|
||||
|
|
|
@ -7,6 +7,7 @@ import { readFile } from 'fs/promises';
|
|||
import type { Server } from 'http';
|
||||
import isbot from 'isbot';
|
||||
import { Logger } from 'n8n-core';
|
||||
import path from 'path';
|
||||
|
||||
import config from '@/config';
|
||||
import { N8N_VERSION, TEMPLATES_DIR, inDevelopment, inTest } from '@/constants';
|
||||
|
@ -67,6 +68,9 @@ export abstract class AbstractServer {
|
|||
this.app.set('view engine', 'handlebars');
|
||||
this.app.set('views', TEMPLATES_DIR);
|
||||
|
||||
const assetsPath: string = path.join(__dirname, '../../../assets');
|
||||
this.app.use(express.static(assetsPath));
|
||||
|
||||
const proxyHops = config.getEnv('proxy_hops');
|
||||
if (proxyHops > 0) this.app.set('trust proxy', proxyHops);
|
||||
|
||||
|
|
|
@ -40,6 +40,7 @@ import {
|
|||
import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||
import { OnShutdown } from '@/decorators/on-shutdown';
|
||||
import { executeErrorWorkflow } from '@/execution-lifecycle/execute-error-workflow';
|
||||
import { ExecutionService } from '@/executions/execution.service';
|
||||
import { ExternalHooks } from '@/external-hooks';
|
||||
import type { IWorkflowDb } from '@/interfaces';
|
||||
|
@ -400,7 +401,7 @@ export class ActiveWorkflowManager {
|
|||
status: 'running',
|
||||
};
|
||||
|
||||
WorkflowExecuteAdditionalData.executeErrorWorkflow(workflowData, fullRunData, mode);
|
||||
executeErrorWorkflow(workflowData, fullRunData, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -110,7 +110,7 @@ export class Reset extends BaseCommand {
|
|||
}
|
||||
|
||||
for (const credential of ownedCredentials) {
|
||||
await Container.get(CredentialsService).delete(credential);
|
||||
await Container.get(CredentialsService).delete(owner, credential.id);
|
||||
}
|
||||
|
||||
await Container.get(AuthProviderSyncHistoryRepository).delete({ providerType: 'ldap' });
|
||||
|
|
|
@ -16,7 +16,7 @@ export class ClearLicenseCommand extends BaseCommand {
|
|||
|
||||
// Attempt to invoke shutdown() to force any floating entitlements to be released
|
||||
const license = Container.get(License);
|
||||
await license.init();
|
||||
await license.init({ isCli: true });
|
||||
try {
|
||||
await license.shutdown();
|
||||
} catch {
|
||||
|
|
|
@ -11,7 +11,7 @@ export class LicenseInfoCommand extends BaseCommand {
|
|||
|
||||
async run() {
|
||||
const license = Container.get(License);
|
||||
await license.init();
|
||||
await license.init({ isCli: true });
|
||||
|
||||
this.logger.info('Printing license information:\n' + license.getInfo());
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { mock } from 'jest-mock-extended';
|
||||
import type { WorkflowExecuteMode as ExecutionMode } from 'n8n-workflow';
|
||||
|
||||
import type { ConcurrencyQueueType } from '@/concurrency/concurrency-control.service';
|
||||
import {
|
||||
CLOUD_TEMP_PRODUCTION_LIMIT,
|
||||
CLOUD_TEMP_REPORTABLE_THRESHOLDS,
|
||||
|
@ -24,61 +25,71 @@ describe('ConcurrencyControlService', () => {
|
|||
|
||||
afterEach(() => {
|
||||
config.set('executions.concurrency.productionLimit', -1);
|
||||
config.set('executions.concurrency.evaluationLimit', -1);
|
||||
config.set('executions.mode', 'integrated');
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should be enabled if production cap is positive', () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
config.set('executions.concurrency.productionLimit', 1);
|
||||
it.each(['production', 'evaluation'])(
|
||||
'should be enabled if %s cap is positive',
|
||||
(type: ConcurrencyQueueType) => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
config.set(`executions.concurrency.${type}Limit`, 1);
|
||||
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
const service = new ConcurrencyControlService(
|
||||
logger,
|
||||
executionRepository,
|
||||
telemetry,
|
||||
eventService,
|
||||
);
|
||||
|
||||
/**
|
||||
* Assert
|
||||
*/
|
||||
// @ts-expect-error Private property
|
||||
expect(service.isEnabled).toBe(true);
|
||||
// @ts-expect-error Private property
|
||||
expect(service.productionQueue).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw if production cap is 0', () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
config.set('executions.concurrency.productionLimit', 0);
|
||||
|
||||
try {
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
new ConcurrencyControlService(logger, executionRepository, telemetry, eventService);
|
||||
} catch (error) {
|
||||
const service = new ConcurrencyControlService(
|
||||
logger,
|
||||
executionRepository,
|
||||
telemetry,
|
||||
eventService,
|
||||
);
|
||||
|
||||
/**
|
||||
* Assert
|
||||
*/
|
||||
expect(error).toBeInstanceOf(InvalidConcurrencyLimitError);
|
||||
}
|
||||
});
|
||||
// @ts-expect-error Private property
|
||||
expect(service.isEnabled).toBe(true);
|
||||
// @ts-expect-error Private property
|
||||
expect(service.queues.get(type)).toBeDefined();
|
||||
// @ts-expect-error Private property
|
||||
expect(service.queues.size).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('should be disabled if production cap is -1', () => {
|
||||
it.each(['production', 'evaluation'])(
|
||||
'should throw if %s cap is 0',
|
||||
(type: ConcurrencyQueueType) => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
config.set(`executions.concurrency.${type}Limit`, 0);
|
||||
|
||||
try {
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
new ConcurrencyControlService(logger, executionRepository, telemetry, eventService);
|
||||
} catch (error) {
|
||||
/**
|
||||
* Assert
|
||||
*/
|
||||
expect(error).toBeInstanceOf(InvalidConcurrencyLimitError);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('should be disabled if both production and evaluation caps are -1', () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
config.set('executions.concurrency.productionLimit', -1);
|
||||
config.set('executions.concurrency.evaluationLimit', -1);
|
||||
|
||||
/**
|
||||
* Act
|
||||
|
@ -97,28 +108,31 @@ describe('ConcurrencyControlService', () => {
|
|||
expect(service.isEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should be disabled if production cap is lower than -1', () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
config.set('executions.concurrency.productionLimit', -2);
|
||||
it.each(['production', 'evaluation'])(
|
||||
'should be disabled if %s cap is lower than -1',
|
||||
(type: ConcurrencyQueueType) => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
config.set(`executions.concurrency.${type}Limit`, -2);
|
||||
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
const service = new ConcurrencyControlService(
|
||||
logger,
|
||||
executionRepository,
|
||||
telemetry,
|
||||
eventService,
|
||||
);
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
const service = new ConcurrencyControlService(
|
||||
logger,
|
||||
executionRepository,
|
||||
telemetry,
|
||||
eventService,
|
||||
);
|
||||
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
// @ts-expect-error Private property
|
||||
expect(service.isEnabled).toBe(false);
|
||||
});
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
// @ts-expect-error Private property
|
||||
expect(service.isEnabled).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('should be disabled on queue mode', () => {
|
||||
/**
|
||||
|
@ -203,6 +217,31 @@ describe('ConcurrencyControlService', () => {
|
|||
*/
|
||||
expect(enqueueSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should enqueue on evaluation mode', async () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
config.set('executions.concurrency.evaluationLimit', 1);
|
||||
|
||||
const service = new ConcurrencyControlService(
|
||||
logger,
|
||||
executionRepository,
|
||||
telemetry,
|
||||
eventService,
|
||||
);
|
||||
const enqueueSpy = jest.spyOn(ConcurrencyQueue.prototype, 'enqueue');
|
||||
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
await service.throttle({ mode: 'evaluation', executionId: '1' });
|
||||
|
||||
/**
|
||||
* Assert
|
||||
*/
|
||||
expect(enqueueSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('release', () => {
|
||||
|
@ -258,6 +297,31 @@ describe('ConcurrencyControlService', () => {
|
|||
*/
|
||||
expect(dequeueSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should dequeue on evaluation mode', () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
config.set('executions.concurrency.evaluationLimit', 1);
|
||||
|
||||
const service = new ConcurrencyControlService(
|
||||
logger,
|
||||
executionRepository,
|
||||
telemetry,
|
||||
eventService,
|
||||
);
|
||||
const dequeueSpy = jest.spyOn(ConcurrencyQueue.prototype, 'dequeue');
|
||||
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
service.release({ mode: 'evaluation' });
|
||||
|
||||
/**
|
||||
* Assert
|
||||
*/
|
||||
expect(dequeueSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
|
@ -316,14 +380,12 @@ describe('ConcurrencyControlService', () => {
|
|||
expect(removeSpy).toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('removeAll', () => {
|
||||
it('should remove all executions from the production queue', async () => {
|
||||
it('should remove an execution on evaluation mode', () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
config.set('executions.concurrency.productionLimit', 2);
|
||||
config.set('executions.concurrency.evaluationLimit', 1);
|
||||
|
||||
const service = new ConcurrencyControlService(
|
||||
logger,
|
||||
|
@ -331,28 +393,112 @@ describe('ConcurrencyControlService', () => {
|
|||
telemetry,
|
||||
eventService,
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(ConcurrencyQueue.prototype, 'getAll')
|
||||
.mockReturnValueOnce(new Set(['1', '2', '3']));
|
||||
|
||||
const removeSpy = jest.spyOn(ConcurrencyQueue.prototype, 'remove');
|
||||
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
await service.removeAll({
|
||||
'1': mock<IExecutingWorkflowData>(),
|
||||
'2': mock<IExecutingWorkflowData>(),
|
||||
'3': mock<IExecutingWorkflowData>(),
|
||||
});
|
||||
service.remove({ mode: 'evaluation', executionId: '1' });
|
||||
|
||||
/**
|
||||
* Assert
|
||||
*/
|
||||
expect(removeSpy).toHaveBeenNthCalledWith(1, '1');
|
||||
expect(removeSpy).toHaveBeenNthCalledWith(2, '2');
|
||||
expect(removeSpy).toHaveBeenNthCalledWith(3, '3');
|
||||
expect(removeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeAll', () => {
|
||||
it.each(['production', 'evaluation'])(
|
||||
'should remove all executions from the %s queue',
|
||||
async (type: ConcurrencyQueueType) => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
config.set(`executions.concurrency.${type}Limit`, 2);
|
||||
|
||||
const service = new ConcurrencyControlService(
|
||||
logger,
|
||||
executionRepository,
|
||||
telemetry,
|
||||
eventService,
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(ConcurrencyQueue.prototype, 'getAll')
|
||||
.mockReturnValueOnce(new Set(['1', '2', '3']));
|
||||
|
||||
const removeSpy = jest.spyOn(ConcurrencyQueue.prototype, 'remove');
|
||||
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
await service.removeAll({
|
||||
'1': mock<IExecutingWorkflowData>(),
|
||||
'2': mock<IExecutingWorkflowData>(),
|
||||
'3': mock<IExecutingWorkflowData>(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Assert
|
||||
*/
|
||||
expect(removeSpy).toHaveBeenNthCalledWith(1, '1');
|
||||
expect(removeSpy).toHaveBeenNthCalledWith(2, '2');
|
||||
expect(removeSpy).toHaveBeenNthCalledWith(3, '3');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('get queue', () => {
|
||||
it('should choose the production queue', async () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
config.set('executions.concurrency.productionLimit', 2);
|
||||
config.set('executions.concurrency.evaluationLimit', 2);
|
||||
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
const service = new ConcurrencyControlService(
|
||||
logger,
|
||||
executionRepository,
|
||||
telemetry,
|
||||
eventService,
|
||||
);
|
||||
// @ts-expect-error Private property
|
||||
const queue = service.getQueue('webhook');
|
||||
|
||||
/**
|
||||
* Assert
|
||||
*/
|
||||
// @ts-expect-error Private property
|
||||
expect(queue).toEqual(service.queues.get('production'));
|
||||
});
|
||||
|
||||
it('should choose the evaluation queue', async () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
config.set('executions.concurrency.productionLimit', 2);
|
||||
config.set('executions.concurrency.evaluationLimit', 2);
|
||||
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
const service = new ConcurrencyControlService(
|
||||
logger,
|
||||
executionRepository,
|
||||
telemetry,
|
||||
eventService,
|
||||
);
|
||||
// @ts-expect-error Private property
|
||||
const queue = service.getQueue('evaluation');
|
||||
|
||||
/**
|
||||
* Assert
|
||||
*/
|
||||
// @ts-expect-error Private property
|
||||
expect(queue).toEqual(service.queues.get('evaluation'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -388,6 +534,32 @@ describe('ConcurrencyControlService', () => {
|
|||
*/
|
||||
expect(enqueueSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing for evaluation executions', async () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
config.set('executions.concurrency.evaluationLimit', -1);
|
||||
|
||||
const service = new ConcurrencyControlService(
|
||||
logger,
|
||||
executionRepository,
|
||||
telemetry,
|
||||
eventService,
|
||||
);
|
||||
const enqueueSpy = jest.spyOn(ConcurrencyQueue.prototype, 'enqueue');
|
||||
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
await service.throttle({ mode: 'evaluation', executionId: '1' });
|
||||
await service.throttle({ mode: 'evaluation', executionId: '2' });
|
||||
|
||||
/**
|
||||
* Assert
|
||||
*/
|
||||
expect(enqueueSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('release', () => {
|
||||
|
@ -415,6 +587,31 @@ describe('ConcurrencyControlService', () => {
|
|||
*/
|
||||
expect(dequeueSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing for evaluation executions', () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
config.set('executions.concurrency.evaluationLimit', -1);
|
||||
|
||||
const service = new ConcurrencyControlService(
|
||||
logger,
|
||||
executionRepository,
|
||||
telemetry,
|
||||
eventService,
|
||||
);
|
||||
const dequeueSpy = jest.spyOn(ConcurrencyQueue.prototype, 'dequeue');
|
||||
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
service.release({ mode: 'evaluation' });
|
||||
|
||||
/**
|
||||
* Assert
|
||||
*/
|
||||
expect(dequeueSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
|
@ -442,6 +639,31 @@ describe('ConcurrencyControlService', () => {
|
|||
*/
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing for evaluation executions', () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
config.set('executions.concurrency.evaluationLimit', -1);
|
||||
|
||||
const service = new ConcurrencyControlService(
|
||||
logger,
|
||||
executionRepository,
|
||||
telemetry,
|
||||
eventService,
|
||||
);
|
||||
const removeSpy = jest.spyOn(ConcurrencyQueue.prototype, 'remove');
|
||||
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
service.remove({ mode: 'evaluation', executionId: '1' });
|
||||
|
||||
/**
|
||||
* Assert
|
||||
*/
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -470,14 +692,17 @@ describe('ConcurrencyControlService', () => {
|
|||
* Act
|
||||
*/
|
||||
// @ts-expect-error Private property
|
||||
service.productionQueue.emit('concurrency-check', {
|
||||
service.queues.get('production').emit('concurrency-check', {
|
||||
capacity: CLOUD_TEMP_PRODUCTION_LIMIT - threshold,
|
||||
});
|
||||
|
||||
/**
|
||||
* Assert
|
||||
*/
|
||||
expect(telemetry.track).toHaveBeenCalledWith('User hit concurrency limit', { threshold });
|
||||
expect(telemetry.track).toHaveBeenCalledWith('User hit concurrency limit', {
|
||||
threshold,
|
||||
concurrencyQueue: 'production',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -500,7 +725,7 @@ describe('ConcurrencyControlService', () => {
|
|||
* Act
|
||||
*/
|
||||
// @ts-expect-error Private property
|
||||
service.productionQueue.emit('concurrency-check', {
|
||||
service.queues.get('production').emit('concurrency-check', {
|
||||
capacity: CLOUD_TEMP_PRODUCTION_LIMIT - threshold,
|
||||
});
|
||||
|
||||
|
@ -532,7 +757,7 @@ describe('ConcurrencyControlService', () => {
|
|||
* Act
|
||||
*/
|
||||
// @ts-expect-error Private property
|
||||
service.productionQueue.emit('concurrency-check', {
|
||||
service.queues.get('production').emit('concurrency-check', {
|
||||
capacity: CLOUD_TEMP_PRODUCTION_LIMIT - threshold,
|
||||
});
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import { capitalize } from 'lodash';
|
||||
import { Logger } from 'n8n-core';
|
||||
import type { WorkflowExecuteMode as ExecutionMode } from 'n8n-workflow';
|
||||
|
||||
|
@ -15,13 +16,15 @@ import { ConcurrencyQueue } from './concurrency-queue';
|
|||
export const CLOUD_TEMP_PRODUCTION_LIMIT = 999;
|
||||
export const CLOUD_TEMP_REPORTABLE_THRESHOLDS = [5, 10, 20, 50, 100, 200];
|
||||
|
||||
export type ConcurrencyQueueType = 'production' | 'evaluation';
|
||||
|
||||
@Service()
|
||||
export class ConcurrencyControlService {
|
||||
private isEnabled: boolean;
|
||||
|
||||
private readonly productionLimit: number;
|
||||
private readonly limits: Map<ConcurrencyQueueType, number>;
|
||||
|
||||
private readonly productionQueue: ConcurrencyQueue;
|
||||
private readonly queues: Map<ConcurrencyQueueType, ConcurrencyQueue>;
|
||||
|
||||
private readonly limitsToReport = CLOUD_TEMP_REPORTABLE_THRESHOLDS.map(
|
||||
(t) => CLOUD_TEMP_PRODUCTION_LIMIT - t,
|
||||
|
@ -35,52 +38,74 @@ export class ConcurrencyControlService {
|
|||
) {
|
||||
this.logger = this.logger.scoped('concurrency');
|
||||
|
||||
this.productionLimit = config.getEnv('executions.concurrency.productionLimit');
|
||||
this.limits = new Map([
|
||||
['production', config.getEnv('executions.concurrency.productionLimit')],
|
||||
['evaluation', config.getEnv('executions.concurrency.evaluationLimit')],
|
||||
]);
|
||||
|
||||
if (this.productionLimit === 0) {
|
||||
throw new InvalidConcurrencyLimitError(this.productionLimit);
|
||||
}
|
||||
this.limits.forEach((limit, type) => {
|
||||
if (limit === 0) {
|
||||
throw new InvalidConcurrencyLimitError(limit);
|
||||
}
|
||||
|
||||
if (this.productionLimit < -1) {
|
||||
this.productionLimit = -1;
|
||||
}
|
||||
if (limit < -1) {
|
||||
this.limits.set(type, -1);
|
||||
}
|
||||
});
|
||||
|
||||
if (this.productionLimit === -1 || config.getEnv('executions.mode') === 'queue') {
|
||||
if (
|
||||
Array.from(this.limits.values()).every((limit) => limit === -1) ||
|
||||
config.getEnv('executions.mode') === 'queue'
|
||||
) {
|
||||
this.isEnabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.productionQueue = new ConcurrencyQueue(this.productionLimit);
|
||||
this.queues = new Map();
|
||||
this.limits.forEach((limit, type) => {
|
||||
if (limit > 0) {
|
||||
this.queues.set(type, new ConcurrencyQueue(limit));
|
||||
}
|
||||
});
|
||||
|
||||
this.logInit();
|
||||
|
||||
this.isEnabled = true;
|
||||
|
||||
this.productionQueue.on('concurrency-check', ({ capacity }) => {
|
||||
if (this.shouldReport(capacity)) {
|
||||
this.telemetry.track('User hit concurrency limit', {
|
||||
threshold: CLOUD_TEMP_PRODUCTION_LIMIT - capacity,
|
||||
});
|
||||
}
|
||||
});
|
||||
this.queues.forEach((queue, type) => {
|
||||
queue.on('concurrency-check', ({ capacity }) => {
|
||||
if (this.shouldReport(capacity)) {
|
||||
this.telemetry.track('User hit concurrency limit', {
|
||||
threshold: CLOUD_TEMP_PRODUCTION_LIMIT - capacity,
|
||||
concurrencyQueue: type,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.productionQueue.on('execution-throttled', ({ executionId }) => {
|
||||
this.logger.debug('Execution throttled', { executionId });
|
||||
this.eventService.emit('execution-throttled', { executionId });
|
||||
});
|
||||
queue.on('execution-throttled', ({ executionId }) => {
|
||||
this.logger.debug('Execution throttled', { executionId, type });
|
||||
this.eventService.emit('execution-throttled', { executionId, type });
|
||||
});
|
||||
|
||||
this.productionQueue.on('execution-released', async (executionId) => {
|
||||
this.logger.debug('Execution released', { executionId });
|
||||
queue.on('execution-released', (executionId) => {
|
||||
this.logger.debug('Execution released', { executionId, type });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an execution is in the production queue.
|
||||
* Check whether an execution is in any of the queues.
|
||||
*/
|
||||
has(executionId: string) {
|
||||
if (!this.isEnabled) return false;
|
||||
|
||||
return this.productionQueue.getAll().has(executionId);
|
||||
for (const queue of this.queues.values()) {
|
||||
if (queue.has(executionId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -89,16 +114,16 @@ export class ConcurrencyControlService {
|
|||
async throttle({ mode, executionId }: { mode: ExecutionMode; executionId: string }) {
|
||||
if (!this.isEnabled || this.isUnlimited(mode)) return;
|
||||
|
||||
await this.productionQueue.enqueue(executionId);
|
||||
await this.getQueue(mode)?.enqueue(executionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release capacity back so the next execution in the production queue can proceed.
|
||||
* Release capacity back so the next execution in the queue can proceed.
|
||||
*/
|
||||
release({ mode }: { mode: ExecutionMode }) {
|
||||
if (!this.isEnabled || this.isUnlimited(mode)) return;
|
||||
|
||||
this.productionQueue.dequeue();
|
||||
this.getQueue(mode)?.dequeue();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -107,7 +132,7 @@ export class ConcurrencyControlService {
|
|||
remove({ mode, executionId }: { mode: ExecutionMode; executionId: string }) {
|
||||
if (!this.isEnabled || this.isUnlimited(mode)) return;
|
||||
|
||||
this.productionQueue.remove(executionId);
|
||||
this.getQueue(mode)?.remove(executionId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -118,11 +143,13 @@ export class ConcurrencyControlService {
|
|||
async removeAll(activeExecutions: { [executionId: string]: IExecutingWorkflowData }) {
|
||||
if (!this.isEnabled) return;
|
||||
|
||||
const enqueuedProductionIds = this.productionQueue.getAll();
|
||||
this.queues.forEach((queue) => {
|
||||
const enqueuedExecutionIds = queue.getAll();
|
||||
|
||||
for (const id of enqueuedProductionIds) {
|
||||
this.productionQueue.remove(id);
|
||||
}
|
||||
for (const id of enqueuedExecutionIds) {
|
||||
queue.remove(id);
|
||||
}
|
||||
});
|
||||
|
||||
const executionIds = Object.entries(activeExecutions)
|
||||
.filter(([_, execution]) => execution.status === 'new' && execution.responsePromise)
|
||||
|
@ -146,15 +173,28 @@ export class ConcurrencyControlService {
|
|||
private logInit() {
|
||||
this.logger.debug('Enabled');
|
||||
|
||||
this.logger.debug(
|
||||
[
|
||||
'Production execution concurrency is',
|
||||
this.productionLimit === -1 ? 'unlimited' : 'limited to ' + this.productionLimit.toString(),
|
||||
].join(' '),
|
||||
);
|
||||
this.limits.forEach((limit, type) => {
|
||||
this.logger.debug(
|
||||
[
|
||||
`${capitalize(type)} execution concurrency is`,
|
||||
limit === -1 ? 'unlimited' : 'limited to ' + limit.toString(),
|
||||
].join(' '),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private isUnlimited(mode: ExecutionMode) {
|
||||
return this.getQueue(mode) === undefined;
|
||||
}
|
||||
|
||||
private shouldReport(capacity: number) {
|
||||
return config.getEnv('deployment.type') === 'cloud' && this.limitsToReport.includes(capacity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the concurrency queue based on the execution mode.
|
||||
*/
|
||||
private getQueue(mode: ExecutionMode) {
|
||||
if (
|
||||
mode === 'error' ||
|
||||
mode === 'integrated' ||
|
||||
|
@ -163,15 +203,13 @@ export class ConcurrencyControlService {
|
|||
mode === 'manual' ||
|
||||
mode === 'retry'
|
||||
) {
|
||||
return true;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (mode === 'webhook' || mode === 'trigger') return this.productionLimit === -1;
|
||||
if (mode === 'webhook' || mode === 'trigger') return this.queues.get('production');
|
||||
|
||||
if (mode === 'evaluation') return this.queues.get('evaluation');
|
||||
|
||||
throw new UnknownExecutionModeError(mode);
|
||||
}
|
||||
|
||||
private shouldReport(capacity: number) {
|
||||
return config.getEnv('deployment.type') === 'cloud' && this.limitsToReport.includes(capacity);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -58,6 +58,10 @@ export class ConcurrencyQueue extends TypedEmitter<ConcurrencyEvents> {
|
|||
return new Set(this.queue.map((item) => item.executionId));
|
||||
}
|
||||
|
||||
has(executionId: string) {
|
||||
return this.queue.some((item) => item.executionId === executionId);
|
||||
}
|
||||
|
||||
private resolveNext() {
|
||||
const item = this.queue.shift();
|
||||
|
||||
|
|
|
@ -35,6 +35,12 @@ export const schema = {
|
|||
default: -1,
|
||||
env: 'N8N_CONCURRENCY_PRODUCTION_LIMIT',
|
||||
},
|
||||
evaluationLimit: {
|
||||
doc: 'Max evaluation executions allowed to run concurrently.',
|
||||
format: Number,
|
||||
default: -1,
|
||||
env: 'N8N_CONCURRENCY_EVALUATION_LIMIT',
|
||||
},
|
||||
},
|
||||
|
||||
// A Workflow times out and gets canceled after this time (seconds).
|
||||
|
|
|
@ -255,7 +255,7 @@ describe('OAuth2CredentialController', () => {
|
|||
type: 'oAuth2Api',
|
||||
}),
|
||||
);
|
||||
expect(res.render).toHaveBeenCalledWith('oauth-callback');
|
||||
expect(res.render).toHaveBeenCalledWith('oauth-callback', { imagePath: 'n8n-logo.png' });
|
||||
});
|
||||
|
||||
it('merges oauthTokenData if it already exists', async () => {
|
||||
|
@ -297,7 +297,7 @@ describe('OAuth2CredentialController', () => {
|
|||
type: 'oAuth2Api',
|
||||
}),
|
||||
);
|
||||
expect(res.render).toHaveBeenCalledWith('oauth-callback');
|
||||
expect(res.render).toHaveBeenCalledWith('oauth-callback', { imagePath: 'n8n-logo.png' });
|
||||
});
|
||||
|
||||
it('overwrites oauthTokenData if it is a string', async () => {
|
||||
|
@ -335,7 +335,7 @@ describe('OAuth2CredentialController', () => {
|
|||
type: 'oAuth2Api',
|
||||
}),
|
||||
);
|
||||
expect(res.render).toHaveBeenCalledWith('oauth-callback');
|
||||
expect(res.render).toHaveBeenCalledWith('oauth-callback', { imagePath: 'n8n-logo.png' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -149,7 +149,7 @@ export class OAuth2CredentialController extends AbstractOAuthController {
|
|||
credentialId: credential.id,
|
||||
});
|
||||
|
||||
return res.render('oauth-callback');
|
||||
return res.render('oauth-callback', { imagePath: 'n8n-logo.png' });
|
||||
} catch (error) {
|
||||
return this.renderCallbackError(
|
||||
res,
|
||||
|
|
|
@ -240,7 +240,7 @@ export class UsersController {
|
|||
}
|
||||
|
||||
for (const credential of ownedCredentials) {
|
||||
await this.credentialsService.delete(credential);
|
||||
await this.credentialsService.delete(userToDelete, credential.id);
|
||||
}
|
||||
|
||||
await this.userService.getManager().transaction(async (trx) => {
|
||||
|
|
|
@ -251,7 +251,7 @@ export class CredentialsController {
|
|||
);
|
||||
}
|
||||
|
||||
await this.credentialsService.delete(credential);
|
||||
await this.credentialsService.delete(req.user, credential.id);
|
||||
|
||||
this.eventService.emit('credentials-deleted', {
|
||||
user: req.user,
|
||||
|
|
|
@ -406,10 +406,26 @@ export class CredentialsService {
|
|||
return result;
|
||||
}
|
||||
|
||||
async delete(credentials: CredentialsEntity) {
|
||||
await this.externalHooks.run('credentials.delete', [credentials.id]);
|
||||
/**
|
||||
* Deletes a credential.
|
||||
*
|
||||
* If the user does not have permission to delete the credential this does
|
||||
* nothing and returns void.
|
||||
*/
|
||||
async delete(user: User, credentialId: string) {
|
||||
await this.externalHooks.run('credentials.delete', [credentialId]);
|
||||
|
||||
await this.credentialsRepository.remove(credentials);
|
||||
const credential = await this.sharedCredentialsRepository.findCredentialForUser(
|
||||
credentialId,
|
||||
user,
|
||||
['credential:delete'],
|
||||
);
|
||||
|
||||
if (!credential) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.credentialsRepository.remove(credential);
|
||||
}
|
||||
|
||||
async test(user: User, credentials: ICredentialsDecrypted) {
|
||||
|
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from '@/databases/entities/abstract-entity';
|
||||
import { TestDefinition } from '@/databases/entities/test-definition.ee';
|
||||
|
||||
type TestRunStatus = 'new' | 'running' | 'completed' | 'error';
|
||||
type TestRunStatus = 'new' | 'running' | 'completed' | 'error' | 'cancelled';
|
||||
|
||||
export type AggregatedTestRunMetrics = Record<string, number | boolean>;
|
||||
|
||||
|
|
|
@ -1,13 +1,16 @@
|
|||
import { GlobalConfig } from '@n8n/config';
|
||||
import type { SqliteConfig } from '@n8n/config/src/configs/database.config';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { SelectQueryBuilder } from '@n8n/typeorm';
|
||||
import { Not, LessThanOrEqual } from '@n8n/typeorm';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { BinaryDataService } from 'n8n-core';
|
||||
import type { IRunExecutionData, IWorkflowBase } from 'n8n-workflow';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { ExecutionEntity } from '@/databases/entities/execution-entity';
|
||||
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||
import type { IExecutionResponse } from '@/interfaces';
|
||||
import { mockInstance, mockEntityManager } from '@test/mocking';
|
||||
|
||||
describe('ExecutionRepository', () => {
|
||||
|
@ -68,4 +71,39 @@ describe('ExecutionRepository', () => {
|
|||
expect(binaryDataService.deleteMany).toHaveBeenCalledWith([{ executionId: '1', workflowId }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateExistingExecution', () => {
|
||||
test.each(['sqlite', 'postgresdb', 'mysqldb'] as const)(
|
||||
'should update execution and data in transaction on %s',
|
||||
async (dbType) => {
|
||||
globalConfig.database.type = dbType;
|
||||
globalConfig.database.sqlite = mock<SqliteConfig>({ poolSize: 1 });
|
||||
|
||||
const executionId = '1';
|
||||
const execution = mock<IExecutionResponse>({
|
||||
id: executionId,
|
||||
data: mock<IRunExecutionData>(),
|
||||
workflowData: mock<IWorkflowBase>(),
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
const txCallback = jest.fn();
|
||||
entityManager.transaction.mockImplementation(async (cb) => {
|
||||
// @ts-expect-error Mock
|
||||
await cb(entityManager);
|
||||
txCallback();
|
||||
});
|
||||
|
||||
await executionRepository.updateExistingExecution(executionId, execution);
|
||||
|
||||
expect(entityManager.transaction).toHaveBeenCalled();
|
||||
expect(entityManager.update).toHaveBeenCalledWith(
|
||||
ExecutionEntity,
|
||||
{ id: executionId },
|
||||
expect.objectContaining({ status: 'success' }),
|
||||
);
|
||||
expect(txCallback).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -45,7 +45,7 @@ import type {
|
|||
import { separate } from '@/utils';
|
||||
|
||||
import { ExecutionDataRepository } from './execution-data.repository';
|
||||
import type { ExecutionData } from '../entities/execution-data';
|
||||
import { ExecutionData } from '../entities/execution-data';
|
||||
import { ExecutionEntity } from '../entities/execution-entity';
|
||||
import { ExecutionMetadata } from '../entities/execution-metadata';
|
||||
import { SharedWorkflow } from '../entities/shared-workflow';
|
||||
|
@ -287,6 +287,15 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
|||
const { executionData, metadata, annotation, ...rest } = execution;
|
||||
const serializedAnnotation = this.serializeAnnotation(annotation);
|
||||
|
||||
if (execution.status === 'success' && executionData?.data === '[]') {
|
||||
this.errorReporter.error('Found successful execution where data is empty stringified array', {
|
||||
extra: {
|
||||
executionId: execution.id,
|
||||
workflowId: executionData?.workflowData.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
...(options?.includeData && {
|
||||
|
@ -378,21 +387,42 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
|||
customData,
|
||||
...executionInformation
|
||||
} = execution;
|
||||
if (Object.keys(executionInformation).length > 0) {
|
||||
await this.update({ id: executionId }, executionInformation);
|
||||
|
||||
const executionData: Partial<ExecutionData> = {};
|
||||
|
||||
if (workflowData) executionData.workflowData = workflowData;
|
||||
if (data) executionData.data = stringify(data);
|
||||
|
||||
const { type: dbType, sqlite: sqliteConfig } = this.globalConfig.database;
|
||||
|
||||
if (dbType === 'sqlite' && sqliteConfig.poolSize === 0) {
|
||||
// TODO: Delete this block of code once the sqlite legacy (non-pooling) driver is dropped.
|
||||
// In the non-pooling sqlite driver we can't use transactions, because that creates nested transactions under highly concurrent loads, leading to errors in the database
|
||||
|
||||
if (Object.keys(executionInformation).length > 0) {
|
||||
await this.update({ id: executionId }, executionInformation);
|
||||
}
|
||||
|
||||
if (Object.keys(executionData).length > 0) {
|
||||
// @ts-expect-error Fix typing
|
||||
await this.executionDataRepository.update({ executionId }, executionData);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (data || workflowData) {
|
||||
const executionData: Partial<ExecutionData> = {};
|
||||
if (workflowData) {
|
||||
executionData.workflowData = workflowData;
|
||||
// All other database drivers should update executions and execution-data atomically
|
||||
|
||||
await this.manager.transaction(async (tx) => {
|
||||
if (Object.keys(executionInformation).length > 0) {
|
||||
await tx.update(ExecutionEntity, { id: executionId }, executionInformation);
|
||||
}
|
||||
if (data) {
|
||||
executionData.data = stringify(data);
|
||||
|
||||
if (Object.keys(executionData).length > 0) {
|
||||
// @ts-expect-error Fix typing
|
||||
await tx.update(ExecutionData, { executionId }, executionData);
|
||||
}
|
||||
// @ts-ignore
|
||||
await this.executionDataRepository.update({ executionId }, executionData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteExecutionsByFilter(
|
||||
|
|
|
@ -35,6 +35,10 @@ export class TestRunRepository extends Repository<TestRun> {
|
|||
return await this.update(id, { status: 'completed', completedAt: new Date(), metrics });
|
||||
}
|
||||
|
||||
async markAsCancelled(id: string) {
|
||||
return await this.update(id, { status: 'cancelled' });
|
||||
}
|
||||
|
||||
async incrementPassed(id: string) {
|
||||
return await this.increment({ id }, 'passedCases', 1);
|
||||
}
|
||||
|
|
|
@ -44,10 +44,12 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
|||
});
|
||||
}
|
||||
|
||||
async getActiveIds() {
|
||||
async getActiveIds({ maxResults }: { maxResults?: number } = {}) {
|
||||
const activeWorkflows = await this.find({
|
||||
select: ['id'],
|
||||
where: { active: true },
|
||||
// 'take' and 'order' are only needed when maxResults is provided:
|
||||
...(maxResults ? { take: maxResults, order: { createdAt: 'ASC' } } : {}),
|
||||
});
|
||||
return activeWorkflows.map((workflow) => workflow.id);
|
||||
}
|
||||
|
|
|
@ -26,6 +26,9 @@ describe('SourceControlImportService', () => {
|
|||
mock(),
|
||||
workflowRepository,
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
mock<InstanceSettings>({ n8nFolder: '/mock/n8n' }),
|
||||
);
|
||||
|
||||
|
|
|
@ -1,10 +1,19 @@
|
|||
import type { SourceControlledFile } from '@n8n/api-types';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
|
||||
import type { TagEntity } from '@/databases/entities/tag-entity';
|
||||
import type { User } from '@/databases/entities/user';
|
||||
import type { Variables } from '@/databases/entities/variables';
|
||||
import type { TagRepository } from '@/databases/repositories/tag.repository';
|
||||
import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee';
|
||||
import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee';
|
||||
|
||||
import type { SourceControlImportService } from '../source-control-import.service.ee';
|
||||
import type { ExportableCredential } from '../types/exportable-credential';
|
||||
import type { SourceControlWorkflowVersionId } from '../types/source-control-workflow-version-id';
|
||||
|
||||
describe('SourceControlService', () => {
|
||||
const preferencesService = new SourceControlPreferencesService(
|
||||
Container.get(InstanceSettings),
|
||||
|
@ -13,20 +22,25 @@ describe('SourceControlService', () => {
|
|||
mock(),
|
||||
mock(),
|
||||
);
|
||||
const sourceControlImportService = mock<SourceControlImportService>();
|
||||
const tagRepository = mock<TagRepository>();
|
||||
const sourceControlService = new SourceControlService(
|
||||
mock(),
|
||||
mock(),
|
||||
preferencesService,
|
||||
mock(),
|
||||
mock(),
|
||||
mock(),
|
||||
sourceControlImportService,
|
||||
tagRepository,
|
||||
mock(),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.spyOn(sourceControlService, 'sanityCheck').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('pushWorkfolder', () => {
|
||||
it('should throw an error if a file is given that is not in the workfolder', async () => {
|
||||
jest.spyOn(sourceControlService, 'sanityCheck').mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
sourceControlService.pushWorkfolder({
|
||||
fileNames: [
|
||||
|
@ -46,4 +60,155 @@ describe('SourceControlService', () => {
|
|||
).rejects.toThrow('File path /etc/passwd is invalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pullWorkfolder', () => {
|
||||
it('does not filter locally created credentials', async () => {
|
||||
// ARRANGE
|
||||
const user = mock<User>();
|
||||
const statuses = [
|
||||
mock<SourceControlledFile>({
|
||||
status: 'created',
|
||||
location: 'local',
|
||||
type: 'credential',
|
||||
}),
|
||||
mock<SourceControlledFile>({
|
||||
status: 'created',
|
||||
location: 'local',
|
||||
type: 'workflow',
|
||||
}),
|
||||
];
|
||||
jest.spyOn(sourceControlService, 'getStatus').mockResolvedValueOnce(statuses);
|
||||
|
||||
// ACT
|
||||
const result = await sourceControlService.pullWorkfolder(user, {});
|
||||
|
||||
// ASSERT
|
||||
expect(result).toMatchObject({ statusCode: 409, statusResult: statuses });
|
||||
});
|
||||
|
||||
it('does not filter remotely deleted credentials', async () => {
|
||||
// ARRANGE
|
||||
const user = mock<User>();
|
||||
const statuses = [
|
||||
mock<SourceControlledFile>({
|
||||
status: 'deleted',
|
||||
location: 'remote',
|
||||
type: 'credential',
|
||||
}),
|
||||
mock<SourceControlledFile>({
|
||||
status: 'created',
|
||||
location: 'local',
|
||||
type: 'workflow',
|
||||
}),
|
||||
];
|
||||
jest.spyOn(sourceControlService, 'getStatus').mockResolvedValueOnce(statuses);
|
||||
|
||||
// ACT
|
||||
const result = await sourceControlService.pullWorkfolder(user, {});
|
||||
|
||||
// ASSERT
|
||||
expect(result).toMatchObject({ statusCode: 409, statusResult: statuses });
|
||||
});
|
||||
|
||||
it('should throw an error if a file is given that is not in the workfolder', async () => {
|
||||
await expect(
|
||||
sourceControlService.pushWorkfolder({
|
||||
fileNames: [
|
||||
{
|
||||
file: '/etc/passwd',
|
||||
id: 'test',
|
||||
name: 'secret-file',
|
||||
type: 'file',
|
||||
status: 'modified',
|
||||
location: 'local',
|
||||
conflict: false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
pushed: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow('File path /etc/passwd is invalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('conflict depends on the value of `direction`', async () => {
|
||||
// ARRANGE
|
||||
|
||||
// Define a credential that does only exist locally.
|
||||
// Pulling this would delete it so it should be marked as a conflict.
|
||||
// Pushing this is conflict free.
|
||||
sourceControlImportService.getRemoteVersionIdsFromFiles.mockResolvedValue([]);
|
||||
sourceControlImportService.getLocalVersionIdsFromDb.mockResolvedValue([
|
||||
mock<SourceControlWorkflowVersionId>(),
|
||||
]);
|
||||
|
||||
// Define a credential that does only exist locally.
|
||||
// Pulling this would delete it so it should be marked as a conflict.
|
||||
// Pushing this is conflict free.
|
||||
sourceControlImportService.getRemoteCredentialsFromFiles.mockResolvedValue([]);
|
||||
sourceControlImportService.getLocalCredentialsFromDb.mockResolvedValue([
|
||||
mock<ExportableCredential & { filename: string }>(),
|
||||
]);
|
||||
|
||||
// Define a variable that does only exist locally.
|
||||
// Pulling this would delete it so it should be marked as a conflict.
|
||||
// Pushing this is conflict free.
|
||||
sourceControlImportService.getRemoteVariablesFromFile.mockResolvedValue([]);
|
||||
sourceControlImportService.getLocalVariablesFromDb.mockResolvedValue([mock<Variables>()]);
|
||||
|
||||
// Define a tag that does only exist locally.
|
||||
// Pulling this would delete it so it should be marked as a conflict.
|
||||
// Pushing this is conflict free.
|
||||
const tag = mock<TagEntity>({ updatedAt: new Date() });
|
||||
tagRepository.find.mockResolvedValue([tag]);
|
||||
sourceControlImportService.getRemoteTagsAndMappingsFromFile.mockResolvedValue({
|
||||
tags: [],
|
||||
mappings: [],
|
||||
});
|
||||
sourceControlImportService.getLocalTagsAndMappingsFromDb.mockResolvedValue({
|
||||
tags: [tag],
|
||||
mappings: [],
|
||||
});
|
||||
|
||||
// ACT
|
||||
const pullResult = await sourceControlService.getStatus({
|
||||
direction: 'pull',
|
||||
verbose: false,
|
||||
preferLocalVersion: false,
|
||||
});
|
||||
|
||||
const pushResult = await sourceControlService.getStatus({
|
||||
direction: 'push',
|
||||
verbose: false,
|
||||
preferLocalVersion: false,
|
||||
});
|
||||
|
||||
// ASSERT
|
||||
console.log(pullResult);
|
||||
console.log(pushResult);
|
||||
|
||||
if (!Array.isArray(pullResult)) {
|
||||
fail('Expected pullResult to be an array.');
|
||||
}
|
||||
if (!Array.isArray(pushResult)) {
|
||||
fail('Expected pushResult to be an array.');
|
||||
}
|
||||
|
||||
expect(pullResult).toHaveLength(4);
|
||||
expect(pushResult).toHaveLength(4);
|
||||
|
||||
expect(pullResult.find((i) => i.type === 'workflow')).toHaveProperty('conflict', true);
|
||||
expect(pushResult.find((i) => i.type === 'workflow')).toHaveProperty('conflict', false);
|
||||
|
||||
expect(pullResult.find((i) => i.type === 'credential')).toHaveProperty('conflict', true);
|
||||
expect(pushResult.find((i) => i.type === 'credential')).toHaveProperty('conflict', false);
|
||||
|
||||
expect(pullResult.find((i) => i.type === 'variables')).toHaveProperty('conflict', true);
|
||||
expect(pushResult.find((i) => i.type === 'variables')).toHaveProperty('conflict', false);
|
||||
|
||||
expect(pullResult.find((i) => i.type === 'tags')).toHaveProperty('conflict', true);
|
||||
expect(pushResult.find((i) => i.type === 'tags')).toHaveProperty('conflict', false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,9 +9,11 @@ import { readFile as fsReadFile } from 'node:fs/promises';
|
|||
import path from 'path';
|
||||
|
||||
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||
import { CredentialsService } from '@/credentials/credentials.service';
|
||||
import type { Project } from '@/databases/entities/project';
|
||||
import { SharedCredentials } from '@/databases/entities/shared-credentials';
|
||||
import type { TagEntity } from '@/databases/entities/tag-entity';
|
||||
import type { User } from '@/databases/entities/user';
|
||||
import type { Variables } from '@/databases/entities/variables';
|
||||
import type { WorkflowTagMapping } from '@/databases/entities/workflow-tag-mapping';
|
||||
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
|
||||
|
@ -25,7 +27,9 @@ import { WorkflowTagMappingRepository } from '@/databases/repositories/workflow-
|
|||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||
import type { IWorkflowToImport } from '@/interfaces';
|
||||
import { isUniqueConstraintError } from '@/response-helper';
|
||||
import { TagService } from '@/services/tag.service';
|
||||
import { assertNever } from '@/utils';
|
||||
import { WorkflowService } from '@/workflows/workflow.service';
|
||||
|
||||
import {
|
||||
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
|
||||
|
@ -62,6 +66,9 @@ export class SourceControlImportService {
|
|||
private readonly variablesRepository: VariablesRepository,
|
||||
private readonly workflowRepository: WorkflowRepository,
|
||||
private readonly workflowTagMappingRepository: WorkflowTagMappingRepository,
|
||||
private readonly workflowService: WorkflowService,
|
||||
private readonly credentialsService: CredentialsService,
|
||||
private readonly tagService: TagService,
|
||||
instanceSettings: InstanceSettings,
|
||||
) {
|
||||
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
|
||||
|
@ -500,6 +507,30 @@ export class SourceControlImportService {
|
|||
return result;
|
||||
}
|
||||
|
||||
async deleteWorkflowsNotInWorkfolder(user: User, candidates: SourceControlledFile[]) {
|
||||
for (const candidate of candidates) {
|
||||
await this.workflowService.delete(user, candidate.id);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCredentialsNotInWorkfolder(user: User, candidates: SourceControlledFile[]) {
|
||||
for (const candidate of candidates) {
|
||||
await this.credentialsService.delete(user, candidate.id);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteVariablesNotInWorkfolder(candidates: SourceControlledFile[]) {
|
||||
for (const candidate of candidates) {
|
||||
await this.variablesService.delete(candidate.id);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTagsNotInWorkfolder(candidates: SourceControlledFile[]) {
|
||||
for (const candidate of candidates) {
|
||||
await this.tagService.delete(candidate.id);
|
||||
}
|
||||
}
|
||||
|
||||
private async findOrCreateOwnerProject(owner: ResourceOwner): Promise<Project | null> {
|
||||
if (typeof owner === 'string' || owner.type === 'personal') {
|
||||
const email = typeof owner === 'string' ? owner : owner.personalEmail;
|
||||
|
|
|
@ -191,7 +191,7 @@ export class SourceControlController {
|
|||
@Body payload: PullWorkFolderRequestDto,
|
||||
): Promise<SourceControlledFile[] | ImportResult | PullResult | undefined> {
|
||||
try {
|
||||
const result = await this.sourceControlService.pullWorkfolder(req.user.id, payload);
|
||||
const result = await this.sourceControlService.pullWorkfolder(req.user, payload);
|
||||
res.statusCode = result.statusCode;
|
||||
return result.statusResult;
|
||||
} catch (error) {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue