diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index 4e77fbd580..852901074f 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -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 diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml index b55d6728d2..8231896145 100644 --- a/.github/workflows/e2e-reusable.yml +++ b/.github/workflows/e2e-reusable.yml @@ -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 diff --git a/.github/workflows/e2e-tests-pr.yml b/.github/workflows/e2e-tests-pr.yml index 047ce2d13a..b244ea86ae 100644 --- a/.github/workflows/e2e-tests-pr.yml +++ b/.github/workflows/e2e-tests-pr.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7335ac5a94..92770f1598 100644 --- a/CHANGELOG.md +++ b/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) diff --git a/cypress/composables/modals/credential-modal.ts b/cypress/composables/modals/credential-modal.ts index 53ba0c3a28..4e6533300a 100644 --- a/cypress/composables/modals/credential-modal.ts +++ b/cypress/composables/modals/credential-modal.ts @@ -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, save = true) if (save) { saveCredential(); closeCredentialModal(); + clearNotifications(); } } diff --git a/cypress/composables/webhooks.ts b/cypress/composables/webhooks.ts new file mode 100644 index 0000000000..8ad5dc6861 --- /dev/null +++ b/cypress/composables/webhooks.ts @@ -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) => 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, + }; +}; diff --git a/cypress/composables/workflow.ts b/cypress/composables/workflow.ts index 66782f02cf..d50c1e1255 100644 --- a/cypress/composables/workflow.ts +++ b/cypress/composables/workflow.ts @@ -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(); +} diff --git a/cypress/e2e/10-undo-redo.cy.ts b/cypress/e2e/10-undo-redo.cy.ts index f54c2de9fa..2931897f03 100644 --- a/cypress/e2e/10-undo-redo.cy.ts +++ b/cypress/e2e/10-undo-redo.cy.ts @@ -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)); diff --git a/cypress/e2e/11-inline-expression-editor.cy.ts b/cypress/e2e/11-inline-expression-editor.cy.ts index a762135a65..e35842293e 100644 --- a/cypress/e2e/11-inline-expression-editor.cy.ts +++ b/cypress/e2e/11-inline-expression-editor.cy.ts @@ -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(); diff --git a/cypress/e2e/12-canvas-actions.cy.ts b/cypress/e2e/12-canvas-actions.cy.ts index e9244a1d12..f63c85dc49 100644 --- a/cypress/e2e/12-canvas-actions.cy.ts +++ b/cypress/e2e/12-canvas-actions.cy.ts @@ -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]); - } - }); }); diff --git a/cypress/e2e/12-canvas.cy.ts b/cypress/e2e/12-canvas.cy.ts index ecfb325de2..be423344fb 100644 --- a/cypress/e2e/12-canvas.cy.ts +++ b/cypress/e2e/12-canvas.cy.ts @@ -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'); - }); }); diff --git a/cypress/e2e/13-pinning.cy.ts b/cypress/e2e/13-pinning.cy.ts index 2d3351f8aa..800f9e417a 100644 --- a/cypress/e2e/13-pinning.cy.ts +++ b/cypress/e2e/13-pinning.cy.ts @@ -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(); diff --git a/cypress/e2e/16-webhook-node.cy.ts b/cypress/e2e/16-webhook-node.cy.ts index 193ada0bcc..e0892a4a0b 100644 --- a/cypress/e2e/16-webhook-node.cy.ts +++ b/cypress/e2e/16-webhook-node.cy.ts @@ -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) => 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(); diff --git a/cypress/e2e/1858-PAY-can-use-context-menu.ts b/cypress/e2e/1858-PAY-can-use-context-menu.ts deleted file mode 100644 index 6727df4166..0000000000 --- a/cypress/e2e/1858-PAY-can-use-context-menu.ts +++ /dev/null @@ -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); - }); -}); diff --git a/cypress/e2e/19-execution.cy.ts b/cypress/e2e/19-execution.cy.ts index 5be2399253..4a39af1d99 100644 --- a/cypress/e2e/19-execution.cy.ts +++ b/cypress/e2e/19-execution.cy.ts @@ -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); - }); }); diff --git a/cypress/e2e/2106-ADO-pinned-data-execution-preview.cy.ts b/cypress/e2e/2106-ADO-pinned-data-execution-preview.cy.ts deleted file mode 100644 index e26a7acb82..0000000000 --- a/cypress/e2e/2106-ADO-pinned-data-execution-preview.cy.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/cypress/e2e/2111-ado-support-pinned-data-in-expressions.cy.ts b/cypress/e2e/2111-ado-support-pinned-data-in-expressions.cy.ts deleted file mode 100644 index 6d2da55b32..0000000000 --- a/cypress/e2e/2111-ado-support-pinned-data-in-expressions.cy.ts +++ /dev/null @@ -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]', - ); - }); -}); diff --git a/cypress/e2e/23-variables.cy.ts b/cypress/e2e/23-variables.cy.ts index c481f25128..a0af3c09ac 100644 --- a/cypress/e2e/23-variables.cy.ts +++ b/cypress/e2e/23-variables.cy.ts @@ -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); diff --git a/cypress/e2e/24-ndv-paired-item.cy.ts b/cypress/e2e/24-ndv-paired-item.cy.ts index 49257a8a12..ccae14f6c9 100644 --- a/cypress/e2e/24-ndv-paired-item.cy.ts +++ b/cypress/e2e/24-ndv-paired-item.cy.ts @@ -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); diff --git a/cypress/e2e/25-stickies.cy.ts b/cypress/e2e/25-stickies.cy.ts index 14c176f17b..da8d6c2674 100644 --- a/cypress/e2e/25-stickies.cy.ts +++ b/cypress/e2e/25-stickies.cy.ts @@ -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); -} diff --git a/cypress/e2e/30-editor-after-route-changes.cy.ts b/cypress/e2e/30-editor-after-route-changes.cy.ts index 307c4a9537..89c64e1156 100644 --- a/cypress/e2e/30-editor-after-route-changes.cy.ts +++ b/cypress/e2e/30-editor-after-route-changes.cy.ts @@ -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'); diff --git a/cypress/e2e/30-langchain.cy.ts b/cypress/e2e/30-langchain.cy.ts index b6f1b56eed..2d0076bc11 100644 --- a/cypress/e2e/30-langchain.cy.ts +++ b/cypress/e2e/30-langchain.cy.ts @@ -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'); + }); }); diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts index 197d585256..04c72c4b2a 100644 --- a/cypress/e2e/39-projects.cy.ts +++ b/cypress/e2e/39-projects.cy.ts @@ -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', () => { diff --git a/cypress/e2e/47-subworkflow-debugging.cy.ts b/cypress/e2e/47-subworkflow-debugging.cy.ts index 725b6b32c4..77aaa4d7f6 100644 --- a/cypress/e2e/47-subworkflow-debugging.cy.ts +++ b/cypress/e2e/47-subworkflow-debugging.cy.ts @@ -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); - }); - }); - }); }); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 8bad424554..22d5b9f49e 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -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('
Hello World'); + ndv.getters.codeEditorFullscreen().should('contain.text', '
Hello World
'); + cy.wait(200); + + ndv.getters.codeEditorDialog().find('.el-dialog__close').click(); + ndv.getters + .parameterInput('html') + .get('.cm-content') + .should('contain.text', '
Hello World
'); + 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', () => { diff --git a/cypress/e2e/7-workflow-actions.cy.ts b/cypress/e2e/7-workflow-actions.cy.ts index f0f3ae019a..079030359a 100644 --- a/cypress/e2e/7-workflow-actions.cy.ts +++ b/cypress/e2e/7-workflow-actions.cy.ts @@ -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'); + }, + ); }); }); }); diff --git a/cypress/e2e/9-expression-editor-modal.cy.ts b/cypress/e2e/9-expression-editor-modal.cy.ts index 840e3d4fc6..42095b06fe 100644 --- a/cypress/e2e/9-expression-editor-modal.cy.ts +++ b/cypress/e2e/9-expression-editor-modal.cy.ts @@ -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(); diff --git a/cypress/fixtures/Test_workflow_chat_partial_execution.json b/cypress/fixtures/Test_workflow_chat_partial_execution.json new file mode 100644 index 0000000000..451ddcf964 --- /dev/null +++ b/cypress/fixtures/Test_workflow_chat_partial_execution.json @@ -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" + } +} diff --git a/cypress/package.json b/cypress/package.json index 6725c46bc6..4ad2d4f199 100644 --- a/cypress/package.json +++ b/cypress/package.json @@ -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 .", diff --git a/cypress/pages/variables.ts b/cypress/pages/variables.ts index 5fb0a64d9a..1bf344cbfc 100644 --- a/cypress/pages/variables.ts +++ b/cypress/pages/variables.ts @@ -68,7 +68,10 @@ export class VariablesPage extends BasePage { }, setRowValue: (row: Chainable>, 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>) => { diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index 69a1becc02..24fda156f6 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -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, diff --git a/cypress/scripts/run-e2e.js b/cypress/scripts/run-e2e.js index 6819d6c824..c7f9ccf749 100755 --- a/cypress/scripts/run-e2e.js +++ b/cypress/scripts/run-e2e.js @@ -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: diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index c414c9fea9..158fe129ec 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -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); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 0fe782499d..297fcfa9b6 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -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/*' }, [ diff --git a/docker/images/n8n/n8n-task-runners.json b/docker/images/n8n/n8n-task-runners.json index 1f38cf6d93..a26053e5e2 100644 --- a/docker/images/n8n/n8n-task-runners.json +++ b/docker/images/n8n/n8n-task-runners.json @@ -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": [ diff --git a/package.json b/package.json index 6cbaac3044..90aa888e98 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index 5a4359198a..1255320c5a 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/api-types", - "version": "0.12.0", + "version": "0.13.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/api-types/src/push/execution.ts b/packages/@n8n/api-types/src/push/execution.ts index 320b3dc264..b87bb67d0f 100644 --- a/packages/@n8n/api-types/src/push/execution.ts +++ b/packages/@n8n/api-types/src/push/execution.ts @@ -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; }; }; diff --git a/packages/@n8n/benchmark/README.md b/packages/@n8n/benchmark/README.md index af8726d0ea..70c20f9731 100644 --- a/packages/@n8n/benchmark/README.md +++ b/packages/@n8n/benchmark/README.md @@ -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 diff --git a/packages/@n8n/benchmark/package.json b/packages/@n8n/benchmark/package.json index f42bd7c508..606f43a7ba 100644 --- a/packages/@n8n/benchmark/package.json +++ b/packages/@n8n/benchmark/package.json @@ -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": { diff --git a/packages/@n8n/benchmark/scripts/n8n-setups/postgres/docker-compose.yml b/packages/@n8n/benchmark/scripts/n8n-setups/postgres/docker-compose.yml index 3cc08227c1..5241971325 100644 --- a/packages/@n8n/benchmark/scripts/n8n-setups/postgres/docker-compose.yml +++ b/packages/@n8n/benchmark/scripts/n8n-setups/postgres/docker-compose.yml @@ -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: diff --git a/packages/@n8n/benchmark/scripts/n8n-setups/scaling-multi-main/docker-compose.yml b/packages/@n8n/benchmark/scripts/n8n-setups/scaling-multi-main/docker-compose.yml index c686f581b3..723c10f00e 100644 --- a/packages/@n8n/benchmark/scripts/n8n-setups/scaling-multi-main/docker-compose.yml +++ b/packages/@n8n/benchmark/scripts/n8n-setups/scaling-multi-main/docker-compose.yml @@ -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: diff --git a/packages/@n8n/benchmark/scripts/n8n-setups/scaling-single-main/docker-compose.yml b/packages/@n8n/benchmark/scripts/n8n-setups/scaling-single-main/docker-compose.yml index fe9e3a26c0..ecefddf5b0 100644 --- a/packages/@n8n/benchmark/scripts/n8n-setups/scaling-single-main/docker-compose.yml +++ b/packages/@n8n/benchmark/scripts/n8n-setups/scaling-single-main/docker-compose.yml @@ -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: diff --git a/packages/@n8n/benchmark/scripts/n8n-setups/sqlite/docker-compose.yml b/packages/@n8n/benchmark/scripts/n8n-setups/sqlite/docker-compose.yml index 20ec0067fa..37e1424cfd 100644 --- a/packages/@n8n/benchmark/scripts/n8n-setups/sqlite/docker-compose.yml +++ b/packages/@n8n/benchmark/scripts/n8n-setups/sqlite/docker-compose.yml @@ -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: diff --git a/packages/@n8n/codemirror-lang/src/expressions/expressions.grammar b/packages/@n8n/codemirror-lang/src/expressions/expressions.grammar index 9217f2c2fb..6e9efee787 100644 --- a/packages/@n8n/codemirror-lang/src/expressions/expressions.grammar +++ b/packages/@n8n/codemirror-lang/src/expressions/expressions.grammar @@ -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 diff --git a/packages/@n8n/codemirror-lang/src/expressions/grammar.ts b/packages/@n8n/codemirror-lang/src/expressions/grammar.ts index bd081b4832..fc3a2c9e31 100644 --- a/packages/@n8n/codemirror-lang/src/expressions/grammar.ts +++ b/packages/@n8n/codemirror-lang/src/expressions/grammar.ts @@ -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, diff --git a/packages/@n8n/codemirror-lang/test/expressions/cases.txt b/packages/@n8n/codemirror-lang/test/expressions/cases.txt index 36f41ddccd..37db1e0bc0 100644 --- a/packages/@n8n/codemirror-lang/test/expressions/cases.txt +++ b/packages/@n8n/codemirror-lang/test/expressions/cases.txt @@ -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) diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index f32da81581..949bafdce5 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/config", - "version": "1.24.0", + "version": "1.26.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/config/src/configs/database.config.ts b/packages/@n8n/config/src/configs/database.config.ts index cebbf2191a..dc8bdde98d 100644 --- a/packages/@n8n/config/src/configs/database.config.ts +++ b/packages/@n8n/config/src/configs/database.config.ts @@ -107,7 +107,7 @@ class MysqlConfig { } @Config -class SqliteConfig { +export class SqliteConfig { /** SQLite database file name */ @Env('DB_SQLITE_DATABASE') database: string = 'database.sqlite'; diff --git a/packages/@n8n/config/src/configs/runners.config.ts b/packages/@n8n/config/src/configs/runners.config.ts index 02ebdf5df9..af7e911877 100644 --- a/packages/@n8n/config/src/configs/runners.config.ts +++ b/packages/@n8n/config/src/configs/runners.config.ts @@ -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 */ diff --git a/packages/@n8n/di/jest.config.js b/packages/@n8n/di/jest.config.js index d6c48554a7..d14f2d60c6 100644 --- a/packages/@n8n/di/jest.config.js +++ b/packages/@n8n/di/jest.config.js @@ -1,2 +1,7 @@ /** @type {import('jest').Config} */ -module.exports = require('../../../jest.config'); +module.exports = { + ...require('../../../jest.config'), + transform: { + '^.+\\.ts$': ['ts-jest', { isolatedModules: false }], + }, +}; diff --git a/packages/@n8n/di/package.json b/packages/@n8n/di/package.json index be327aefb6..88774017d3 100644 --- a/packages/@n8n/di/package.json +++ b/packages/@n8n/di/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/di", - "version": "0.2.0", + "version": "0.3.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/di/src/__tests__/circular-depedency.test.ts b/packages/@n8n/di/src/__tests__/circular-depedency.test.ts new file mode 100644 index 0000000000..66bce38f7b --- /dev/null +++ b/packages/@n8n/di/src/__tests__/circular-depedency.test.ts @@ -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', + ); + }); + }); +}); diff --git a/packages/@n8n/di/src/__tests__/fixtures/ServiceA.ts b/packages/@n8n/di/src/__tests__/fixtures/ServiceA.ts new file mode 100644 index 0000000000..83f4c90430 --- /dev/null +++ b/packages/@n8n/di/src/__tests__/fixtures/ServiceA.ts @@ -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) {} +} diff --git a/packages/@n8n/di/src/__tests__/fixtures/ServiceB.ts b/packages/@n8n/di/src/__tests__/fixtures/ServiceB.ts new file mode 100644 index 0000000000..a0dbd1908a --- /dev/null +++ b/packages/@n8n/di/src/__tests__/fixtures/ServiceB.ts @@ -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) {} +} diff --git a/packages/@n8n/di/src/di.ts b/packages/@n8n/di/src/di.ts index a4acb98474..08d86eae07 100644 --- a/packages/@n8n/di/src/di.ts +++ b/packages/@n8n/di/src/di.ts @@ -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(

(paramType: Constructable

) => - this.get(paramType), - ); + + const dependencies = paramTypes.map(

(paramType: Constructable

, 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; } diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts index cd44cb114b..58d90ff90b 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsOpenAI/EmbeddingsOpenAi.node.ts @@ -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: [ diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts index cf24d944de..99d7345939 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.ts @@ -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 { 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; diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/__tests__/loadModels.test.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/__tests__/loadModels.test.ts new file mode 100644 index 0000000000..b2f728678f --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/__tests__/loadModels.test.ts @@ -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; + let mockOpenAI: jest.Mocked; + + beforeEach(() => { + mockContext = { + getCredentials: jest.fn().mockResolvedValue({ + apiKey: 'test-api-key', + }), + getNodeParameter: jest.fn().mockReturnValue(''), + } as unknown as jest.Mocked; + + // 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).mockImplementation(() => mockOpenAIInstance); + + mockOpenAI = OpenAI as jest.Mocked; + }); + + 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' }, + ]); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/loadModels.ts b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/loadModels.ts new file mode 100644 index 0000000000..966be4f940 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LMChatOpenAi/methods/loadModels.ts @@ -0,0 +1,37 @@ +import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow'; +import OpenAI from 'openai'; + +export async function searchModels( + this: ILoadOptionsFunctions, + filter?: string, +): Promise { + 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; +} diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts index f8b7d2bb3e..05c2475556 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleGemini/LmChatGoogleGemini.node.ts @@ -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, diff --git a/packages/@n8n/nodes-langchain/nodes/memory/MemoryPostgresChat/MemoryPostgresChat.node.ts b/packages/@n8n/nodes-langchain/nodes/memory/MemoryPostgresChat/MemoryPostgresChat.node.ts index 18fd76e3c5..81be577f79 100644 --- a/packages/@n8n/nodes-langchain/nodes/memory/MemoryPostgresChat/MemoryPostgresChat.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/memory/MemoryPostgresChat/MemoryPostgresChat.node.ts @@ -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), }; } diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts index d9d5ee611a..852453b622 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts @@ -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'; diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts index 441126c985..e393d32e55 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/shared/createVectorStoreNode.ts @@ -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]; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/methods/listSearch.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/methods/listSearch.ts index dcf056618a..919a11c078 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/methods/listSearch.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/methods/listSearch.ts @@ -76,9 +76,15 @@ export async function modelSearch( this: ILoadOptionsFunctions, filter?: string, ): Promise { + 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); } diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index c7cc10aa77..b93deba806 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -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", diff --git a/packages/@n8n/storybook/package.json b/packages/@n8n/storybook/package.json index 6018fbd4b2..9506a7188e 100644 --- a/packages/@n8n/storybook/package.json +++ b/packages/@n8n/storybook/package.json @@ -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" } } diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index c32ea2714e..ec53c53dcd 100644 --- a/packages/@n8n/task-runner/package.json +++ b/packages/@n8n/task-runner/package.json @@ -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:" } } diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts index 6ef479239d..a666240098 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/js-task-runner.test.ts @@ -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 = {}; + 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(); + }); + }); }); diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/require-resolver.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/require-resolver.test.ts new file mode 100644 index 0000000000..16e0f99e7c --- /dev/null +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/require-resolver.test.ts @@ -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'", + ); + }); + }); +}); diff --git a/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts b/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts index 58750327d7..25a8f8dfa4 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/js-task-runner.ts @@ -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 | '*') { + 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; @@ -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, diff --git a/packages/@n8n/task-runner/src/js-task-runner/require-resolver.ts b/packages/@n8n/task-runner/src/js-task-runner/require-resolver.ts index ffa00c0441..4facbd365c 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/require-resolver.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/require-resolver.ts @@ -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 | null; + allowedBuiltInModules: Set | '*'; /** * 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 | null; + allowedExternalModules: Set | '*'; }; export type RequireResolver = (request: string) => unknown; @@ -24,8 +24,8 @@ export function createRequireResolver({ allowedExternalModules, }: RequireResolverOpts) { return (request: string) => { - const checkIsAllowed = (allowList: Set | null, moduleName: string) => { - return allowList ? allowList.has(moduleName) : true; + const checkIsAllowed = (allowList: Set | '*', moduleName: string) => { + return allowList === '*' || allowList.has(moduleName); }; const isAllowed = isBuiltin(request) diff --git a/packages/@n8n_io/eslint-config/base.js b/packages/@n8n_io/eslint-config/base.js index 0864a20a7b..dc4ad37025 100644 --- a/packages/@n8n_io/eslint-config/base.js +++ b/packages/@n8n_io/eslint-config/base.js @@ -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', // ****************************************************************** diff --git a/packages/@n8n_io/eslint-config/local-rules.js b/packages/@n8n_io/eslint-config/local-rules.js index 92b0f6669e..d32d72f89c 100644 --- a/packages/@n8n_io/eslint-config/local-rules.js +++ b/packages/@n8n_io/eslint-config/local-rules.js @@ -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', diff --git a/packages/@n8n_io/eslint-config/local-rules.test.js b/packages/@n8n_io/eslint-config/local-rules.test.js index 86589ac67f..ca32c37a40 100644 --- a/packages/@n8n_io/eslint-config/local-rules.test.js +++ b/packages/@n8n_io/eslint-config/local-rules.test.js @@ -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(); +}`, + }, + ], +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index a65fe2960d..15511d5ef8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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:", diff --git a/packages/cli/src/__tests__/license.test.ts b/packages/cli/src/__tests__/license.test.ts index d48c361dcc..0e26d0d81c 100644 --- a/packages/cli/src/__tests__/license.test.ts +++ b/packages/cli/src/__tests__/license.test.ts @@ -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({ + 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(); diff --git a/packages/cli/src/abstract-server.ts b/packages/cli/src/abstract-server.ts index a9340b0a87..8a2ba38b4a 100644 --- a/packages/cli/src/abstract-server.ts +++ b/packages/cli/src/abstract-server.ts @@ -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); diff --git a/packages/cli/src/active-workflow-manager.ts b/packages/cli/src/active-workflow-manager.ts index a002bc4054..403e60f51d 100644 --- a/packages/cli/src/active-workflow-manager.ts +++ b/packages/cli/src/active-workflow-manager.ts @@ -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); } /** diff --git a/packages/cli/src/commands/ldap/reset.ts b/packages/cli/src/commands/ldap/reset.ts index edbf988434..8d3eb8da94 100644 --- a/packages/cli/src/commands/ldap/reset.ts +++ b/packages/cli/src/commands/ldap/reset.ts @@ -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' }); diff --git a/packages/cli/src/commands/license/clear.ts b/packages/cli/src/commands/license/clear.ts index 03a2ea4dd4..732401ed47 100644 --- a/packages/cli/src/commands/license/clear.ts +++ b/packages/cli/src/commands/license/clear.ts @@ -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 { diff --git a/packages/cli/src/commands/license/info.ts b/packages/cli/src/commands/license/info.ts index cc99e925f7..f99648d0d5 100644 --- a/packages/cli/src/commands/license/info.ts +++ b/packages/cli/src/commands/license/info.ts @@ -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()); } diff --git a/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts b/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts index 6511ae4d03..a20c1769da 100644 --- a/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts +++ b/packages/cli/src/concurrency/__tests__/concurrency-control.service.test.ts @@ -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(), - '2': mock(), - '3': mock(), - }); + 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(), + '2': mock(), + '3': mock(), + }); + + /** + * 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, }); diff --git a/packages/cli/src/concurrency/concurrency-control.service.ts b/packages/cli/src/concurrency/concurrency-control.service.ts index 6088d0f4c3..1984148c29 100644 --- a/packages/cli/src/concurrency/concurrency-control.service.ts +++ b/packages/cli/src/concurrency/concurrency-control.service.ts @@ -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; - private readonly productionQueue: ConcurrencyQueue; + private readonly queues: Map; 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); - } } diff --git a/packages/cli/src/concurrency/concurrency-queue.ts b/packages/cli/src/concurrency/concurrency-queue.ts index 900018889a..eac9e478d1 100644 --- a/packages/cli/src/concurrency/concurrency-queue.ts +++ b/packages/cli/src/concurrency/concurrency-queue.ts @@ -58,6 +58,10 @@ export class ConcurrencyQueue extends TypedEmitter { 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(); diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 15c3a59969..0e7f747fba 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -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). diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts index 1984d12f59..5281378fe0 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts @@ -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' }); }); }); }); diff --git a/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts b/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts index e188670fde..c4c24de0bc 100644 --- a/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts +++ b/packages/cli/src/controllers/oauth/oauth2-credential.controller.ts @@ -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, diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index 3177c2c23b..80d38fcfcd 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -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) => { diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index 73888e1977..6b4fd8472a 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -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, diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index 52a1a3e88d..18d01a198a 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -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) { diff --git a/packages/cli/src/databases/entities/test-run.ee.ts b/packages/cli/src/databases/entities/test-run.ee.ts index 39d8e16ddd..79c7bc9f07 100644 --- a/packages/cli/src/databases/entities/test-run.ee.ts +++ b/packages/cli/src/databases/entities/test-run.ee.ts @@ -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; diff --git a/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts b/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts index 8e36f0189b..a6195b4f67 100644 --- a/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts +++ b/packages/cli/src/databases/repositories/__tests__/execution.repository.test.ts @@ -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({ poolSize: 1 }); + + const executionId = '1'; + const execution = mock({ + id: executionId, + data: mock(), + workflowData: mock(), + 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); + }, + ); + }); }); diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 160b7ace87..9c24cea5c2 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -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 { 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 { customData, ...executionInformation } = execution; - if (Object.keys(executionInformation).length > 0) { - await this.update({ id: executionId }, executionInformation); + + const executionData: Partial = {}; + + 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 = {}; - 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( diff --git a/packages/cli/src/databases/repositories/test-run.repository.ee.ts b/packages/cli/src/databases/repositories/test-run.repository.ee.ts index 037844734f..728f0bc464 100644 --- a/packages/cli/src/databases/repositories/test-run.repository.ee.ts +++ b/packages/cli/src/databases/repositories/test-run.repository.ee.ts @@ -35,6 +35,10 @@ export class TestRunRepository extends Repository { 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); } diff --git a/packages/cli/src/databases/repositories/workflow.repository.ts b/packages/cli/src/databases/repositories/workflow.repository.ts index 0952ae3cdc..c05e466eca 100644 --- a/packages/cli/src/databases/repositories/workflow.repository.ts +++ b/packages/cli/src/databases/repositories/workflow.repository.ts @@ -44,10 +44,12 @@ export class WorkflowRepository extends Repository { }); } - 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); } diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts index fff60bd566..cc817368cd 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-import.service.ee.test.ts @@ -26,6 +26,9 @@ describe('SourceControlImportService', () => { mock(), workflowRepository, mock(), + mock(), + mock(), + mock(), mock({ n8nFolder: '/mock/n8n' }), ); diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts index 56b58646c9..43a13ed6ba 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control.service.test.ts @@ -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(); + const tagRepository = mock(); 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(); + const statuses = [ + mock({ + status: 'created', + location: 'local', + type: 'credential', + }), + mock({ + 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(); + const statuses = [ + mock({ + status: 'deleted', + location: 'remote', + type: 'credential', + }), + mock({ + 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(), + ]); + + // 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(), + ]); + + // 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()]); + + // 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({ 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); + }); + }); }); diff --git a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts index 21640dad0e..3c416c0b4c 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts @@ -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 { if (typeof owner === 'string' || owner.type === 'personal') { const email = typeof owner === 'string' ? owner : owner.personalEmail; diff --git a/packages/cli/src/environments.ee/source-control/source-control.controller.ee.ts b/packages/cli/src/environments.ee/source-control/source-control.controller.ee.ts index a7dd00d199..1f243a1447 100644 --- a/packages/cli/src/environments.ee/source-control/source-control.controller.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control.controller.ee.ts @@ -191,7 +191,7 @@ export class SourceControlController { @Body payload: PullWorkFolderRequestDto, ): Promise { 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) { diff --git a/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts index 3b330fffa3..2bd040ee3a 100644 --- a/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control.service.ee.ts @@ -322,8 +322,44 @@ export class SourceControlService { }; } + private getConflicts(files: SourceControlledFile[]): SourceControlledFile[] { + return files.filter((file) => file.conflict || file.status === 'modified'); + } + + private getWorkflowsToImport(files: SourceControlledFile[]): SourceControlledFile[] { + return files.filter((e) => e.type === 'workflow' && e.status !== 'deleted'); + } + + private getWorkflowsToDelete(files: SourceControlledFile[]): SourceControlledFile[] { + return files.filter((e) => e.type === 'workflow' && e.status === 'deleted'); + } + + private getCredentialsToImport(files: SourceControlledFile[]): SourceControlledFile[] { + return files.filter((e) => e.type === 'credential' && e.status !== 'deleted'); + } + + private getCredentialsToDelete(files: SourceControlledFile[]): SourceControlledFile[] { + return files.filter((e) => e.type === 'credential' && e.status === 'deleted'); + } + + private getTagsToImport(files: SourceControlledFile[]): SourceControlledFile | undefined { + return files.find((e) => e.type === 'tags' && e.status !== 'deleted'); + } + + private getTagsToDelete(files: SourceControlledFile[]): SourceControlledFile[] { + return files.filter((e) => e.type === 'tags' && e.status === 'deleted'); + } + + private getVariablesToImport(files: SourceControlledFile[]): SourceControlledFile | undefined { + return files.find((e) => e.type === 'variables' && e.status !== 'deleted'); + } + + private getVariablesToDelete(files: SourceControlledFile[]): SourceControlledFile[] { + return files.filter((e) => e.type === 'variables' && e.status === 'deleted'); + } + async pullWorkfolder( - userId: User['id'], + user: User, options: PullWorkFolderRequestDto, ): Promise<{ statusCode: number; statusResult: SourceControlledFile[] }> { await this.sanityCheck(); @@ -334,58 +370,51 @@ export class SourceControlService { preferLocalVersion: false, })) as SourceControlledFile[]; - // filter out items that will not effect a local change and thus should not - // trigger a conflict warning in the frontend - const filteredResult = statusResult.filter((e) => { - // locally created credentials will not create a conflict on pull - if (e.status === 'created' && e.location === 'local') { - return false; - } - // remotely deleted credentials will not delete local credentials - if (e.type === 'credential' && e.status === 'deleted') { - return false; - } - return true; - }); - - if (!options.force) { - const possibleConflicts = filteredResult?.filter( - (file) => (file.conflict || file.status === 'modified') && file.type === 'workflow', - ); + if (options.force !== true) { + const possibleConflicts = this.getConflicts(statusResult); if (possibleConflicts?.length > 0) { await this.gitService.resetBranch(); return { statusCode: 409, - statusResult: filteredResult, + statusResult, }; } } - const workflowsToBeImported = statusResult.filter( - (e) => e.type === 'workflow' && e.status !== 'deleted', - ); + const workflowsToBeImported = this.getWorkflowsToImport(statusResult); await this.sourceControlImportService.importWorkflowFromWorkFolder( workflowsToBeImported, - userId, + user.id, ); - - const credentialsToBeImported = statusResult.filter( - (e) => e.type === 'credential' && e.status !== 'deleted', + const workflowsToBeDeleted = this.getWorkflowsToDelete(statusResult); + await this.sourceControlImportService.deleteWorkflowsNotInWorkfolder( + user, + workflowsToBeDeleted, ); + const credentialsToBeImported = this.getCredentialsToImport(statusResult); await this.sourceControlImportService.importCredentialsFromWorkFolder( credentialsToBeImported, - userId, + user.id, + ); + const credentialsToBeDeleted = this.getCredentialsToDelete(statusResult); + await this.sourceControlImportService.deleteCredentialsNotInWorkfolder( + user, + credentialsToBeDeleted, ); - const tagsToBeImported = statusResult.find((e) => e.type === 'tags'); + const tagsToBeImported = this.getTagsToImport(statusResult); if (tagsToBeImported) { await this.sourceControlImportService.importTagsFromWorkFolder(tagsToBeImported); } + const tagsToBeDeleted = this.getTagsToDelete(statusResult); + await this.sourceControlImportService.deleteTagsNotInWorkfolder(tagsToBeDeleted); - const variablesToBeImported = statusResult.find((e) => e.type === 'variables'); + const variablesToBeImported = this.getVariablesToImport(statusResult); if (variablesToBeImported) { await this.sourceControlImportService.importVariablesFromWorkFolder(variablesToBeImported); } + const variablesToBeDeleted = this.getVariablesToDelete(statusResult); + await this.sourceControlImportService.deleteVariablesNotInWorkfolder(variablesToBeDeleted); // #region Tracking Information this.eventService.emit( @@ -396,7 +425,7 @@ export class SourceControlService { return { statusCode: 200, - statusResult: filteredResult, + statusResult, }; } @@ -536,7 +565,7 @@ export class SourceControlService { type: 'workflow', status: options.direction === 'push' ? 'created' : 'deleted', location: options.direction === 'push' ? 'local' : 'remote', - conflict: false, + conflict: options.direction === 'push' ? false : true, file: item.filename, updatedAt: item.updatedAt ?? new Date().toISOString(), }); @@ -617,7 +646,7 @@ export class SourceControlService { type: 'credential', status: options.direction === 'push' ? 'created' : 'deleted', location: options.direction === 'push' ? 'local' : 'remote', - conflict: false, + conflict: options.direction === 'push' ? false : true, file: item.filename, updatedAt: new Date().toISOString(), }); @@ -669,26 +698,47 @@ export class SourceControlService { } }); - if ( - varMissingInLocal.length > 0 || - varMissingInRemote.length > 0 || - varModifiedInEither.length > 0 - ) { - if (options.direction === 'pull' && varRemoteIds.length === 0) { - // if there's nothing to pull, don't show difference as modified - } else { - sourceControlledFiles.push({ - id: 'variables', - name: 'variables', - type: 'variables', - status: 'modified', - location: options.direction === 'push' ? 'local' : 'remote', - conflict: false, - file: getVariablesPath(this.gitFolder), - updatedAt: new Date().toISOString(), - }); - } - } + varMissingInLocal.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.key, + type: 'variables', + status: options.direction === 'push' ? 'deleted' : 'created', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: false, + file: getVariablesPath(this.gitFolder), + updatedAt: new Date().toISOString(), + }); + }); + + varMissingInRemote.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.key, + type: 'variables', + status: options.direction === 'push' ? 'created' : 'deleted', + location: options.direction === 'push' ? 'local' : 'remote', + // if the we pull and the file is missing in the remote, we will delete + // it locally, which is communicated by marking this as a conflict + conflict: options.direction === 'push' ? false : true, + file: getVariablesPath(this.gitFolder), + updatedAt: new Date().toISOString(), + }); + }); + + varModifiedInEither.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.key, + type: 'variables', + status: 'modified', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: true, + file: getVariablesPath(this.gitFolder), + updatedAt: new Date().toISOString(), + }); + }); + return { varMissingInLocal, varMissingInRemote, @@ -743,32 +793,44 @@ export class SourceControlService { ) === -1, ); - if ( - tagsMissingInLocal.length > 0 || - tagsMissingInRemote.length > 0 || - tagsModifiedInEither.length > 0 || - mappingsMissingInLocal.length > 0 || - mappingsMissingInRemote.length > 0 - ) { - if ( - options.direction === 'pull' && - tagMappingsRemote.tags.length === 0 && - tagMappingsRemote.mappings.length === 0 - ) { - // if there's nothing to pull, don't show difference as modified - } else { - sourceControlledFiles.push({ - id: 'mappings', - name: 'tags', - type: 'tags', - status: 'modified', - location: options.direction === 'push' ? 'local' : 'remote', - conflict: false, - file: getTagsPath(this.gitFolder), - updatedAt: lastUpdatedTag[0]?.updatedAt.toISOString(), - }); - } - } + tagsMissingInLocal.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.name, + type: 'tags', + status: options.direction === 'push' ? 'deleted' : 'created', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: false, + file: getTagsPath(this.gitFolder), + updatedAt: lastUpdatedTag[0]?.updatedAt.toISOString(), + }); + }); + tagsMissingInRemote.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.name, + type: 'tags', + status: options.direction === 'push' ? 'created' : 'deleted', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: options.direction === 'push' ? false : true, + file: getTagsPath(this.gitFolder), + updatedAt: lastUpdatedTag[0]?.updatedAt.toISOString(), + }); + }); + + tagsModifiedInEither.forEach((item) => { + sourceControlledFiles.push({ + id: item.id, + name: item.name, + type: 'tags', + status: 'modified', + location: options.direction === 'push' ? 'local' : 'remote', + conflict: true, + file: getTagsPath(this.gitFolder), + updatedAt: lastUpdatedTag[0]?.updatedAt.toISOString(), + }); + }); + return { tagsMissingInLocal, tagsMissingInRemote, diff --git a/packages/cli/src/errors/response-errors/not-implemented.error.ts b/packages/cli/src/errors/response-errors/not-implemented.error.ts new file mode 100644 index 0000000000..f6c66391ff --- /dev/null +++ b/packages/cli/src/errors/response-errors/not-implemented.error.ts @@ -0,0 +1,7 @@ +import { ResponseError } from './abstract/response.error'; + +export class NotImplementedError extends ResponseError { + constructor(message: string, hint: string | undefined = undefined) { + super(message, 501, 501, hint); + } +} diff --git a/packages/cli/src/evaluation.ee/test-definitions.types.ee.ts b/packages/cli/src/evaluation.ee/test-definitions.types.ee.ts index b7441a2763..9b0507f248 100644 --- a/packages/cli/src/evaluation.ee/test-definitions.types.ee.ts +++ b/packages/cli/src/evaluation.ee/test-definitions.types.ee.ts @@ -92,4 +92,6 @@ export declare namespace TestRunsRequest { type GetOne = AuthenticatedRequest; type Delete = AuthenticatedRequest; + + type Cancel = AuthenticatedRequest; } diff --git a/packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts b/packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts index 5d8fe3fe10..026b5d2eb8 100644 --- a/packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts +++ b/packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts @@ -132,6 +132,12 @@ function mockEvaluationExecutionData(metrics: Record) { const errorReporter = mock(); const logger = mockLogger(); +async function mockLongExecutionPromise(data: IRun, delay: number): Promise { + return await new Promise((resolve) => { + setTimeout(() => resolve(data), delay); + }); +} + describe('TestRunnerService', () => { const executionRepository = mock(); const workflowRepository = mock(); @@ -168,11 +174,7 @@ describe('TestRunnerService', () => { }); afterEach(() => { - activeExecutions.getPostExecutePromise.mockClear(); - workflowRunner.run.mockClear(); - testRunRepository.createTestRun.mockClear(); - testRunRepository.markAsRunning.mockClear(); - testRunRepository.markAsCompleted.mockClear(); + jest.resetAllMocks(); testRunRepository.incrementFailed.mockClear(); testRunRepository.incrementPassed.mockClear(); }); @@ -633,4 +635,87 @@ describe('TestRunnerService', () => { }), }); }); + + describe('Test Run cancellation', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + test('should cancel test run', async () => { + const testRunnerService = new TestRunnerService( + logger, + workflowRepository, + workflowRunner, + executionRepository, + activeExecutions, + testRunRepository, + testMetricRepository, + mockNodeTypes, + mock(), + ); + + workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ + id: 'workflow-under-test-id', + ...wfUnderTestJson, + }); + + workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({ + id: 'evaluation-workflow-id', + ...wfEvaluationJson, + }); + + workflowRunner.run.mockResolvedValueOnce('some-execution-id'); + workflowRunner.run.mockResolvedValueOnce('some-execution-id-2'); + workflowRunner.run.mockResolvedValueOnce('some-execution-id-3'); + workflowRunner.run.mockResolvedValueOnce('some-execution-id-4'); + + // Mock long execution of workflow under test + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id') + .mockReturnValue(mockLongExecutionPromise(mockExecutionData(), 1000)); + + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id-3') + .mockReturnValue(mockLongExecutionPromise(mockExecutionData(), 1000)); + + // Mock executions of evaluation workflow + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id-2') + .mockReturnValue( + mockLongExecutionPromise(mockEvaluationExecutionData({ metric1: 1, metric2: 0 }), 1000), + ); + + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id-4') + .mockReturnValue( + mockLongExecutionPromise(mockEvaluationExecutionData({ metric1: 0.5 }), 1000), + ); + + // Do not await here to test canceling + void testRunnerService.runTest( + mock(), + mock({ + workflowId: 'workflow-under-test-id', + evaluationWorkflowId: 'evaluation-workflow-id', + mockedNodes: [{ id: '72256d90-3a67-4e29-b032-47df4e5768af' }], + }), + ); + + // Simulate the moment when first test case is running (wf under test execution) + await jest.advanceTimersByTimeAsync(100); + expect(workflowRunner.run).toHaveBeenCalledTimes(1); + + const abortController = (testRunnerService as any).abortControllers.get('test-run-id'); + expect(abortController).toBeDefined(); + + await testRunnerService.cancelTestRun('test-run-id'); + + expect(abortController.signal.aborted).toBe(true); + expect(activeExecutions.stopExecution).toBeCalledWith('some-execution-id'); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + }); }); diff --git a/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts b/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts index 732c803814..a594e15c05 100644 --- a/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts +++ b/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts @@ -1,7 +1,7 @@ import { Service } from '@n8n/di'; import { parse } from 'flatted'; import { ErrorReporter, Logger } from 'n8n-core'; -import { NodeConnectionType, Workflow } from 'n8n-workflow'; +import { ExecutionCancelledError, NodeConnectionType, Workflow } from 'n8n-workflow'; import type { IDataObject, IRun, @@ -15,6 +15,7 @@ import assert from 'node:assert'; import { ActiveExecutions } from '@/active-executions'; import type { ExecutionEntity } from '@/databases/entities/execution-entity'; import type { MockedNodeItem, TestDefinition } from '@/databases/entities/test-definition.ee'; +import type { TestRun } from '@/databases/entities/test-run.ee'; import type { User } from '@/databases/entities/user'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; @@ -38,6 +39,8 @@ import { createPinData, getPastExecutionTriggerNode } from './utils.ee'; */ @Service() export class TestRunnerService { + private abortControllers: Map = new Map(); + constructor( private readonly logger: Logger, private readonly workflowRepository: WorkflowRepository, @@ -101,7 +104,13 @@ export class TestRunnerService { pastExecutionWorkflowData: IWorkflowBase, mockedNodes: MockedNodeItem[], userId: string, + abortSignal: AbortSignal, ): Promise { + // Do not run if the test run is cancelled + if (abortSignal.aborted) { + return; + } + // Create pin data from the past execution data const pinData = createPinData( workflow, @@ -125,6 +134,11 @@ export class TestRunnerService { const executionId = await this.workflowRunner.run(data); assert(executionId); + // Listen to the abort signal to stop the execution in case test run is cancelled + abortSignal.addEventListener('abort', () => { + this.activeExecutions.stopExecution(executionId); + }); + // Wait for the execution to finish const executePromise = this.activeExecutions.getPostExecutePromise(executionId); @@ -138,8 +152,14 @@ export class TestRunnerService { evaluationWorkflow: WorkflowEntity, expectedData: IRunData, actualData: IRunData, + abortSignal: AbortSignal, testRunId?: string, ) { + // Do not run if the test run is cancelled + if (abortSignal.aborted) { + return; + } + // Prepare the evaluation wf input data. // Provide both the expected data and the actual data const evaluationInputData = { @@ -164,6 +184,11 @@ export class TestRunnerService { const executionId = await this.workflowRunner.run(data); assert(executionId); + // Listen to the abort signal to stop the execution in case test run is cancelled + abortSignal.addEventListener('abort', () => { + this.activeExecutions.stopExecution(executionId); + }); + // Wait for the execution to finish const executePromise = this.activeExecutions.getPostExecutePromise(executionId); @@ -217,99 +242,156 @@ export class TestRunnerService { const testRun = await this.testRunRepository.createTestRun(test.id); assert(testRun, 'Unable to create a test run'); - // 1. Make test cases from previous executions + // 0.1 Initialize AbortController + const abortController = new AbortController(); + this.abortControllers.set(testRun.id, abortController); - // Select executions with the annotation tag and workflow ID of the test. - // Fetch only ids to reduce the data transfer. - const pastExecutions: ReadonlyArray> = - await this.executionRepository - .createQueryBuilder('execution') - .select('execution.id') - .leftJoin('execution.annotation', 'annotation') - .leftJoin('annotation.tags', 'annotationTag') - .where('annotationTag.id = :tagId', { tagId: test.annotationTagId }) - .andWhere('execution.workflowId = :workflowId', { workflowId: test.workflowId }) - .getMany(); + const abortSignal = abortController.signal; + try { + // 1. Make test cases from previous executions - this.logger.debug('Found past executions', { count: pastExecutions.length }); + // Select executions with the annotation tag and workflow ID of the test. + // Fetch only ids to reduce the data transfer. + const pastExecutions: ReadonlyArray> = + await this.executionRepository + .createQueryBuilder('execution') + .select('execution.id') + .leftJoin('execution.annotation', 'annotation') + .leftJoin('annotation.tags', 'annotationTag') + .where('annotationTag.id = :tagId', { tagId: test.annotationTagId }) + .andWhere('execution.workflowId = :workflowId', { workflowId: test.workflowId }) + .getMany(); - // Get the metrics to collect from the evaluation workflow - const testMetricNames = await this.getTestMetricNames(test.id); + this.logger.debug('Found past executions', { count: pastExecutions.length }); - // 2. Run over all the test cases + // Get the metrics to collect from the evaluation workflow + const testMetricNames = await this.getTestMetricNames(test.id); - await this.testRunRepository.markAsRunning(testRun.id, pastExecutions.length); + // 2. Run over all the test cases + await this.testRunRepository.markAsRunning(testRun.id, pastExecutions.length); - // Object to collect the results of the evaluation workflow executions - const metrics = new EvaluationMetrics(testMetricNames); + // Object to collect the results of the evaluation workflow executions + const metrics = new EvaluationMetrics(testMetricNames); - for (const { id: pastExecutionId } of pastExecutions) { - this.logger.debug('Running test case', { pastExecutionId }); - - try { - // Fetch past execution with data - const pastExecution = await this.executionRepository.findOne({ - where: { id: pastExecutionId }, - relations: ['executionData', 'metadata'], - }); - assert(pastExecution, 'Execution not found'); - - const executionData = parse(pastExecution.executionData.data) as IRunExecutionData; - - // Run the test case and wait for it to finish - const testCaseExecution = await this.runTestCase( - workflow, - executionData, - pastExecution.executionData.workflowData, - test.mockedNodes, - user.id, - ); - - this.logger.debug('Test case execution finished', { pastExecutionId }); - - // In case of a permission check issue, the test case execution will be undefined. - // Skip them, increment the failed count and continue with the next test case - if (!testCaseExecution) { - await this.testRunRepository.incrementFailed(testRun.id); - continue; + for (const { id: pastExecutionId } of pastExecutions) { + if (abortSignal.aborted) { + this.logger.debug('Test run was cancelled', { + testId: test.id, + stoppedOn: pastExecutionId, + }); + break; } - // Collect the results of the test case execution - const testCaseRunData = testCaseExecution.data.resultData.runData; + this.logger.debug('Running test case', { pastExecutionId }); - // Get the original runData from the test case execution data - const originalRunData = executionData.resultData.runData; + try { + // Fetch past execution with data + const pastExecution = await this.executionRepository.findOne({ + where: { id: pastExecutionId }, + relations: ['executionData', 'metadata'], + }); + assert(pastExecution, 'Execution not found'); - // Run the evaluation workflow with the original and new run data - const evalExecution = await this.runTestCaseEvaluation( - evaluationWorkflow, - originalRunData, - testCaseRunData, - testRun.id, - ); - assert(evalExecution); + const executionData = parse(pastExecution.executionData.data) as IRunExecutionData; - this.logger.debug('Evaluation execution finished', { pastExecutionId }); + // Run the test case and wait for it to finish + const testCaseExecution = await this.runTestCase( + workflow, + executionData, + pastExecution.executionData.workflowData, + test.mockedNodes, + user.id, + abortSignal, + ); - metrics.addResults(this.extractEvaluationResult(evalExecution)); + this.logger.debug('Test case execution finished', { pastExecutionId }); - if (evalExecution.data.resultData.error) { + // In case of a permission check issue, the test case execution will be undefined. + // Skip them, increment the failed count and continue with the next test case + if (!testCaseExecution) { + await this.testRunRepository.incrementFailed(testRun.id); + continue; + } + + // Collect the results of the test case execution + const testCaseRunData = testCaseExecution.data.resultData.runData; + + // Get the original runData from the test case execution data + const originalRunData = executionData.resultData.runData; + + // Run the evaluation workflow with the original and new run data + const evalExecution = await this.runTestCaseEvaluation( + evaluationWorkflow, + originalRunData, + testCaseRunData, + abortSignal, + testRun.id, + ); + assert(evalExecution); + + this.logger.debug('Evaluation execution finished', { pastExecutionId }); + + // Extract the output of the last node executed in the evaluation workflow + metrics.addResults(this.extractEvaluationResult(evalExecution)); + + if (evalExecution.data.resultData.error) { + await this.testRunRepository.incrementFailed(testRun.id); + } else { + await this.testRunRepository.incrementPassed(testRun.id); + } + } catch (e) { + // In case of an unexpected error, increment the failed count and continue with the next test case await this.testRunRepository.incrementFailed(testRun.id); - } else { - await this.testRunRepository.incrementPassed(testRun.id); - } - } catch (e) { - // In case of an unexpected error, increment the failed count and continue with the next test case - await this.testRunRepository.incrementFailed(testRun.id); - this.errorReporter.error(e); + this.errorReporter.error(e); + } } + + // Mark the test run as completed or cancelled + if (abortSignal.aborted) { + await this.testRunRepository.markAsCancelled(testRun.id); + } else { + const aggregatedMetrics = metrics.getAggregatedMetrics(); + await this.testRunRepository.markAsCompleted(testRun.id, aggregatedMetrics); + + this.logger.debug('Test run finished', { testId: test.id }); + } + } catch (e) { + if (e instanceof ExecutionCancelledError) { + this.logger.debug('Evaluation execution was cancelled. Cancelling test run', { + testRunId: testRun.id, + stoppedOn: e.extra?.executionId, + }); + + await this.testRunRepository.markAsCancelled(testRun.id); + } else { + throw e; + } + } finally { + // Clean up abort controller + this.abortControllers.delete(testRun.id); } + } - const aggregatedMetrics = metrics.getAggregatedMetrics(); + /** + * Checks if the test run in a cancellable state. + */ + canBeCancelled(testRun: TestRun) { + return testRun.status !== 'running' && testRun.status !== 'new'; + } - await this.testRunRepository.markAsCompleted(testRun.id, aggregatedMetrics); - - this.logger.debug('Test run finished', { testId: test.id }); + /** + * Cancels the test run with the given ID. + * TODO: Implement the cancellation of the test run in a multi-main scenario + */ + async cancelTestRun(testRunId: string) { + const abortController = this.abortControllers.get(testRunId); + if (abortController) { + abortController.abort(); + this.abortControllers.delete(testRunId); + } else { + // If there is no abort controller - just mark the test run as cancelled + await this.testRunRepository.markAsCancelled(testRunId); + } } } diff --git a/packages/cli/src/evaluation.ee/test-runs.controller.ee.ts b/packages/cli/src/evaluation.ee/test-runs.controller.ee.ts index aae71376e4..7e95cb4dce 100644 --- a/packages/cli/src/evaluation.ee/test-runs.controller.ee.ts +++ b/packages/cli/src/evaluation.ee/test-runs.controller.ee.ts @@ -1,7 +1,13 @@ +import express from 'express'; +import { InstanceSettings } from 'n8n-core'; + import { TestRunRepository } from '@/databases/repositories/test-run.repository.ee'; -import { Delete, Get, RestController } from '@/decorators'; +import { Delete, Get, Post, RestController } from '@/decorators'; +import { ConflictError } from '@/errors/response-errors/conflict.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { NotImplementedError } from '@/errors/response-errors/not-implemented.error'; import { TestRunsRequest } from '@/evaluation.ee/test-definitions.types.ee'; +import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee'; import { listQueryMiddleware } from '@/middlewares'; import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service'; @@ -12,9 +18,12 @@ export class TestRunsController { constructor( private readonly testDefinitionService: TestDefinitionService, private readonly testRunRepository: TestRunRepository, + private readonly testRunnerService: TestRunnerService, + private readonly instanceSettings: InstanceSettings, ) {} - /** This method is used in multiple places in the controller to get the test definition + /** + * This method is used in multiple places in the controller to get the test definition * (or just check that it exists and the user has access to it). */ private async getTestDefinition( @@ -34,6 +43,23 @@ export class TestRunsController { return testDefinition; } + /** + * Get the test run (or just check that it exists and the user has access to it) + */ + private async getTestRun( + req: TestRunsRequest.GetOne | TestRunsRequest.Delete | TestRunsRequest.Cancel, + ) { + const { id: testRunId, testDefinitionId } = req.params; + + const testRun = await this.testRunRepository.findOne({ + where: { id: testRunId, testDefinition: { id: testDefinitionId } }, + }); + + if (!testRun) throw new NotFoundError('Test run not found'); + + return testRun; + } + @Get('/:testDefinitionId/runs', { middlewares: listQueryMiddleware }) async getMany(req: TestRunsRequest.GetMany) { const { testDefinitionId } = req.params; @@ -45,33 +71,43 @@ export class TestRunsController { @Get('/:testDefinitionId/runs/:id') async getOne(req: TestRunsRequest.GetOne) { - const { id: testRunId, testDefinitionId } = req.params; - await this.getTestDefinition(req); - const testRun = await this.testRunRepository.findOne({ - where: { id: testRunId, testDefinition: { id: testDefinitionId } }, - }); - - if (!testRun) throw new NotFoundError('Test run not found'); - - return testRun; + return await this.getTestRun(req); } @Delete('/:testDefinitionId/runs/:id') async delete(req: TestRunsRequest.Delete) { - const { id: testRunId, testDefinitionId } = req.params; + const { id: testRunId } = req.params; + // Check test definition and test run exist await this.getTestDefinition(req); - - const testRun = await this.testRunRepository.findOne({ - where: { id: testRunId, testDefinition: { id: testDefinitionId } }, - }); - - if (!testRun) throw new NotFoundError('Test run not found'); + await this.getTestRun(req); await this.testRunRepository.delete({ id: testRunId }); return { success: true }; } + + @Post('/:testDefinitionId/runs/:id/cancel') + async cancel(req: TestRunsRequest.Cancel, res: express.Response) { + if (this.instanceSettings.isMultiMain) { + throw new NotImplementedError('Cancelling test runs is not yet supported in multi-main mode'); + } + + const { id: testRunId } = req.params; + + // Check test definition and test run exist + await this.getTestDefinition(req); + const testRun = await this.getTestRun(req); + + if (this.testRunnerService.canBeCancelled(testRun)) { + const message = `The test run "${testRunId}" cannot be cancelled`; + throw new ConflictError(message); + } + + await this.testRunnerService.cancelTestRun(testRunId); + + res.status(202).json({ success: true }); + } } diff --git a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts index 4727c8ef72..1729509210 100644 --- a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts @@ -934,6 +934,7 @@ describe('LogStreamingEventRelay', () => { it('should log on `execution-throttled` event', () => { const event: RelayEventMap['execution-throttled'] = { executionId: 'exec123456', + type: 'production', }; eventService.emit('execution-throttled', event); @@ -942,6 +943,7 @@ describe('LogStreamingEventRelay', () => { eventName: 'n8n.execution.throttled', payload: { executionId: 'exec123456', + type: 'production', }, }); }); diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 0b21454f3b..b174c66587 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -6,6 +6,7 @@ import type { IWorkflowExecutionDataProcess, } from 'n8n-workflow'; +import type { ConcurrencyQueueType } from '@/concurrency/concurrency-control.service'; import type { AuthProviderType } from '@/databases/entities/auth-identity'; import type { GlobalRole, User } from '@/databases/entities/user'; import type { IWorkflowDb } from '@/interfaces'; @@ -337,6 +338,7 @@ export type RelayEventMap = { 'execution-throttled': { executionId: string; + type: ConcurrencyQueueType; }; 'execution-started-during-bootup': { diff --git a/packages/cli/src/events/relays/log-streaming.event-relay.ts b/packages/cli/src/events/relays/log-streaming.event-relay.ts index b048b09a83..76b578451d 100644 --- a/packages/cli/src/events/relays/log-streaming.event-relay.ts +++ b/packages/cli/src/events/relays/log-streaming.event-relay.ts @@ -385,10 +385,10 @@ export class LogStreamingEventRelay extends EventRelay { // #region Execution - private executionThrottled({ executionId }: RelayEventMap['execution-throttled']) { + private executionThrottled({ executionId, type }: RelayEventMap['execution-throttled']) { void this.eventBus.sendExecutionEvent({ eventName: 'n8n.execution.throttled', - payload: { executionId }, + payload: { executionId, type }, }); } diff --git a/packages/cli/src/events/relays/telemetry.event-relay.ts b/packages/cli/src/events/relays/telemetry.event-relay.ts index 67fbacb107..8d0f050dee 100644 --- a/packages/cli/src/events/relays/telemetry.event-relay.ts +++ b/packages/cli/src/events/relays/telemetry.event-relay.ts @@ -15,7 +15,7 @@ import { SharedWorkflowRepository } from '@/databases/repositories/shared-workfl import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { EventService } from '@/events/event.service'; import type { RelayEventMap } from '@/events/maps/relay.event-map'; -import { determineFinalExecutionStatus } from '@/execution-lifecycle-hooks/shared/shared-hook-functions'; +import { determineFinalExecutionStatus } from '@/execution-lifecycle/shared/shared-hook-functions'; import type { IExecutionTrackProperties } from '@/interfaces'; import { License } from '@/license'; import { NodeTypes } from '@/node-types'; diff --git a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts new file mode 100644 index 0000000000..5ea8e411ad --- /dev/null +++ b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts @@ -0,0 +1,617 @@ +import { stringify } from 'flatted'; +import { mock } from 'jest-mock-extended'; +import { BinaryDataService, ErrorReporter, InstanceSettings, Logger } from 'n8n-core'; +import { ExpressionError, WorkflowHooks } from 'n8n-workflow'; +import type { + IRunExecutionData, + ITaskData, + Workflow, + IDataObject, + IRun, + INode, + IWorkflowBase, +} from 'n8n-workflow'; + +import config from '@/config'; +import type { Project } from '@/databases/entities/project'; +import { ExecutionRepository } from '@/databases/repositories/execution.repository'; +import { EventService } from '@/events/event.service'; +import { ExternalHooks } from '@/external-hooks'; +import { Push } from '@/push'; +import { OwnershipService } from '@/services/ownership.service'; +import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; +import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; +import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; +import { mockInstance } from '@test/mocking'; + +import { + getWorkflowHooksMain, + getWorkflowHooksWorkerExecuter, + getWorkflowHooksWorkerMain, +} from '../execution-lifecycle-hooks'; + +describe('Execution Lifecycle Hooks', () => { + mockInstance(Logger); + mockInstance(InstanceSettings); + const errorReporter = mockInstance(ErrorReporter); + const eventService = mockInstance(EventService); + const executionRepository = mockInstance(ExecutionRepository); + const externalHooks = mockInstance(ExternalHooks); + const push = mockInstance(Push); + const workflowStaticDataService = mockInstance(WorkflowStaticDataService); + const workflowStatisticsService = mockInstance(WorkflowStatisticsService); + const binaryDataService = mockInstance(BinaryDataService); + const ownershipService = mockInstance(OwnershipService); + const workflowExecutionService = mockInstance(WorkflowExecutionService); + + const nodeName = 'Test Node'; + const node = mock(); + const workflowId = 'test-workflow-id'; + const executionId = 'test-execution-id'; + const workflowData: IWorkflowBase = { + id: workflowId, + name: 'Test Workflow', + active: true, + connections: {}, + nodes: [], + settings: {}, + createdAt: new Date(), + updatedAt: new Date(), + }; + const workflow = mock(); + const staticData = mock(); + const taskData = mock(); + const runExecutionData = mock(); + const successfulRun = mock({ + status: 'success', + finished: true, + waitTill: undefined, + }); + const failedRun = mock({ + status: 'error', + finished: true, + waitTill: undefined, + }); + const waitingRun = mock({ + finished: true, + status: 'waiting', + waitTill: new Date(), + }); + const expressionError = new ExpressionError('Error'); + const executionMode = 'manual'; + const pushRef = 'test-push-ref'; + const retryOf = 'test-retry-of'; + + const now = new Date('2025-01-13T18:25:50.267Z'); + jest.useFakeTimers({ now }); + + beforeEach(() => { + jest.clearAllMocks(); + workflowData.settings = {}; + successfulRun.data = { + resultData: { + runData: {}, + }, + }; + failedRun.data = { + resultData: { + runData: {}, + error: expressionError, + }, + }; + }); + + describe('getWorkflowHooksMain', () => { + let hooks: WorkflowHooks; + beforeEach(() => { + hooks = getWorkflowHooksMain( + { + executionMode, + workflowData, + pushRef, + retryOf, + }, + executionId, + ); + }); + + it('should setup the correct set of hooks', () => { + expect(hooks).toBeInstanceOf(WorkflowHooks); + expect(hooks.mode).toBe('manual'); + expect(hooks.executionId).toBe(executionId); + expect(hooks.workflowData).toEqual(workflowData); + expect(hooks.pushRef).toEqual('test-push-ref'); + expect(hooks.retryOf).toEqual('test-retry-of'); + + const { hookFunctions } = hooks; + expect(hookFunctions.nodeExecuteBefore).toHaveLength(2); + expect(hookFunctions.nodeExecuteAfter).toHaveLength(3); + expect(hookFunctions.workflowExecuteBefore).toHaveLength(2); + expect(hookFunctions.workflowExecuteAfter).toHaveLength(2); + expect(hookFunctions.nodeFetchedData).toHaveLength(1); + expect(hookFunctions.sendResponse).toBeUndefined(); + }); + + describe('nodeExecuteBefore', () => { + it('should send nodeExecuteBefore push event', async () => { + await hooks.executeHookFunctions('nodeExecuteBefore', [nodeName]); + + expect(push.send).toHaveBeenCalledWith( + { type: 'nodeExecuteBefore', data: { executionId, nodeName } }, + pushRef, + ); + }); + + it('should emit node-pre-execute event', async () => { + await hooks.executeHookFunctions('nodeExecuteBefore', [nodeName]); + + expect(eventService.emit).toHaveBeenCalledWith('node-pre-execute', { + executionId, + workflow: workflowData, + nodeName, + }); + }); + }); + + describe('nodeExecuteAfter', () => { + it('should send nodeExecuteAfter push event', async () => { + await hooks.executeHookFunctions('nodeExecuteAfter', [ + nodeName, + taskData, + runExecutionData, + ]); + + expect(push.send).toHaveBeenCalledWith( + { type: 'nodeExecuteAfter', data: { executionId, nodeName, data: taskData } }, + pushRef, + ); + }); + + it('should emit node-post-execute event', async () => { + await hooks.executeHookFunctions('nodeExecuteAfter', [ + nodeName, + taskData, + runExecutionData, + ]); + + expect(eventService.emit).toHaveBeenCalledWith('node-post-execute', { + executionId, + workflow: workflowData, + nodeName, + }); + }); + + it('should save execution progress when enabled', async () => { + workflowData.settings = { saveExecutionProgress: true }; + + await hooks.executeHookFunctions('nodeExecuteAfter', [ + nodeName, + taskData, + runExecutionData, + ]); + + expect(executionRepository.findSingleExecution).toHaveBeenCalledWith(executionId, { + includeData: true, + unflattenData: true, + }); + }); + + it('should not save execution progress when disabled', async () => { + workflowData.settings = { saveExecutionProgress: false }; + + await hooks.executeHookFunctions('nodeExecuteAfter', [ + nodeName, + taskData, + runExecutionData, + ]); + + expect(executionRepository.findSingleExecution).not.toHaveBeenCalled(); + }); + }); + + describe('workflowExecuteBefore', () => { + it('should send executionStarted push event', async () => { + await hooks.executeHookFunctions('workflowExecuteBefore', [workflow, runExecutionData]); + + expect(push.send).toHaveBeenCalledWith( + { + type: 'executionStarted', + data: { + executionId, + mode: executionMode, + retryOf, + workflowId: 'test-workflow-id', + workflowName: 'Test Workflow', + startedAt: now, + flattedRunData: '[{}]', + }, + }, + pushRef, + ); + }); + + it('should not call eventService', async () => { + await hooks.executeHookFunctions('workflowExecuteBefore', [workflow, runExecutionData]); + + expect(eventService.emit).not.toHaveBeenCalled(); + }); + + it('should run workflow.preExecute external hook', async () => { + await hooks.executeHookFunctions('workflowExecuteBefore', [workflow, runExecutionData]); + + expect(externalHooks.run).toHaveBeenCalledWith('workflow.preExecute', [ + workflow, + executionMode, + ]); + }); + }); + + describe('workflowExecuteAfter', () => { + it('should send executionFinished push event', async () => { + await hooks.executeHookFunctions('workflowExecuteAfter', [successfulRun, {}]); + expect(eventService.emit).not.toHaveBeenCalled(); + expect(push.send).toHaveBeenCalledWith( + { + type: 'executionFinished', + data: { + executionId, + rawData: stringify(successfulRun.data), + status: 'success', + workflowId: 'test-workflow-id', + }, + }, + pushRef, + ); + }); + + it('should send executionWaiting push event', async () => { + await hooks.executeHookFunctions('workflowExecuteAfter', [waitingRun, {}]); + + expect(push.send).toHaveBeenCalledWith( + { + type: 'executionWaiting', + data: { executionId }, + }, + pushRef, + ); + }); + + describe('saving static data', () => { + it('should skip saving static data for manual executions', async () => { + hooks.mode = 'manual'; + + await hooks.executeHookFunctions('workflowExecuteAfter', [successfulRun, staticData]); + + expect(workflowStaticDataService.saveStaticDataById).not.toHaveBeenCalled(); + }); + + it('should save static data for prod executions', async () => { + hooks.mode = 'trigger'; + + await hooks.executeHookFunctions('workflowExecuteAfter', [successfulRun, staticData]); + + expect(workflowStaticDataService.saveStaticDataById).toHaveBeenCalledWith( + workflowId, + staticData, + ); + }); + + it('should handle static data saving errors', async () => { + hooks.mode = 'trigger'; + const error = new Error('Static data save failed'); + workflowStaticDataService.saveStaticDataById.mockRejectedValueOnce(error); + + await hooks.executeHookFunctions('workflowExecuteAfter', [successfulRun, staticData]); + + expect(errorReporter.error).toHaveBeenCalledWith(error); + }); + }); + + describe('saving execution data', () => { + it('should update execution with proper data', async () => { + await hooks.executeHookFunctions('workflowExecuteAfter', [successfulRun, {}]); + + expect(executionRepository.updateExistingExecution).toHaveBeenCalledWith( + executionId, + expect.objectContaining({ + finished: true, + status: 'success', + }), + ); + }); + + it('should handle errors when updating execution', async () => { + const error = new Error('Failed to update execution'); + executionRepository.updateExistingExecution.mockRejectedValueOnce(error); + + await hooks.executeHookFunctions('workflowExecuteAfter', [successfulRun, {}]); + + expect(errorReporter.error).toHaveBeenCalledWith(error); + }); + + it('should not delete unfinished executions', async () => { + const unfinishedRun = mock({ finished: false, status: 'running' }); + + await hooks.executeHookFunctions('workflowExecuteAfter', [unfinishedRun, {}]); + + expect(executionRepository.hardDelete).not.toHaveBeenCalled(); + }); + + it('should not delete waiting executions', async () => { + await hooks.executeHookFunctions('workflowExecuteAfter', [waitingRun, {}]); + + expect(executionRepository.hardDelete).not.toHaveBeenCalled(); + }); + + it('should soft delete manual executions when manual saving is disabled', async () => { + hooks.workflowData.settings = { saveManualExecutions: false }; + + await hooks.executeHookFunctions('workflowExecuteAfter', [successfulRun, {}]); + + expect(executionRepository.softDelete).toHaveBeenCalledWith(executionId); + }); + + it('should not soft delete manual executions with waitTill', async () => { + hooks.workflowData.settings = { saveManualExecutions: false }; + + await hooks.executeHookFunctions('workflowExecuteAfter', [waitingRun, {}]); + + expect(executionRepository.softDelete).not.toHaveBeenCalled(); + }); + }); + + describe('error workflow', () => { + it('should not execute error workflow for manual executions', async () => { + hooks.mode = 'manual'; + + await hooks.executeHookFunctions('workflowExecuteAfter', [failedRun, {}]); + + expect(workflowExecutionService.executeErrorWorkflow).not.toHaveBeenCalled(); + }); + + it('should execute error workflow for failed non-manual executions', async () => { + hooks.mode = 'trigger'; + const errorWorkflow = 'error-workflow-id'; + workflowData.settings = { errorWorkflow }; + const project = mock(); + ownershipService.getWorkflowProjectCached + .calledWith(workflowId) + .mockResolvedValue(project); + + await hooks.executeHookFunctions('workflowExecuteAfter', [failedRun, {}]); + + expect(workflowExecutionService.executeErrorWorkflow).toHaveBeenCalledWith( + errorWorkflow, + { + workflow: { + id: workflowId, + name: workflowData.name, + }, + execution: { + id: executionId, + error: expressionError, + mode: 'trigger', + retryOf, + lastNodeExecuted: undefined, + url: `http://localhost:5678/workflow/${workflowId}/executions/${executionId}`, + }, + }, + project, + ); + }); + }); + + it('should restore binary data IDs after workflow execution for webhooks', async () => { + config.set('binaryDataManager.mode', 'filesystem'); + hooks.mode = 'webhook'; + (successfulRun.data.resultData.runData = { + [nodeName]: [ + { + executionTime: 1, + startTime: 1, + source: [], + data: { + main: [ + [ + { + json: {}, + binary: { + data: { + id: `filesystem-v2:workflows/${workflowId}/executions/temp/binary_data/123`, + data: '', + mimeType: 'text/plain', + }, + }, + }, + ], + ], + }, + }, + ], + }), + await hooks.executeHookFunctions('workflowExecuteAfter', [successfulRun, {}]); + + expect(binaryDataService.rename).toHaveBeenCalledWith( + 'workflows/test-workflow-id/executions/temp/binary_data/123', + 'workflows/test-workflow-id/executions/test-execution-id/binary_data/123', + ); + }); + }); + + describe('statistics events', () => { + it('workflowExecuteAfter should emit workflowExecutionCompleted statistics event', async () => { + await hooks.executeHookFunctions('workflowExecuteAfter', [successfulRun, {}]); + + expect(workflowStatisticsService.emit).toHaveBeenCalledWith('workflowExecutionCompleted', { + workflowData, + fullRunData: successfulRun, + }); + }); + + it('nodeFetchedData should handle nodeFetchedData statistics event', async () => { + await hooks.executeHookFunctions('nodeFetchedData', [workflowId, node]); + + expect(workflowStatisticsService.emit).toHaveBeenCalledWith('nodeFetchedData', { + workflowId, + node, + }); + }); + }); + }); + + describe('getWorkflowHooksWorkerMain', () => { + let hooks: WorkflowHooks; + + beforeEach(() => { + hooks = getWorkflowHooksWorkerMain(executionMode, executionId, workflowData, { + pushRef, + retryOf, + }); + }); + + it('should setup the correct set of hooks', () => { + expect(hooks).toBeInstanceOf(WorkflowHooks); + expect(hooks.mode).toBe('manual'); + expect(hooks.executionId).toBe(executionId); + expect(hooks.workflowData).toEqual(workflowData); + expect(hooks.pushRef).toEqual('test-push-ref'); + expect(hooks.retryOf).toEqual('test-retry-of'); + + const { hookFunctions } = hooks; + expect(hookFunctions.nodeExecuteBefore).toHaveLength(0); + expect(hookFunctions.nodeExecuteAfter).toHaveLength(0); + expect(hookFunctions.workflowExecuteBefore).toHaveLength(1); + expect(hookFunctions.workflowExecuteAfter).toHaveLength(1); + }); + + describe('workflowExecuteBefore', () => { + it('should run the workflow.preExecute external hook', async () => { + await hooks.executeHookFunctions('workflowExecuteBefore', [workflow, runExecutionData]); + + expect(externalHooks.run).toHaveBeenCalledWith('workflow.preExecute', [ + workflow, + executionMode, + ]); + }); + }); + + describe('workflowExecuteAfter', () => { + it('should delete successful executions when success saving is disabled', async () => { + workflowData.settings = { + saveDataSuccessExecution: 'none', + saveDataErrorExecution: 'all', + }; + const hooks = getWorkflowHooksWorkerMain('webhook', executionId, workflowData, { + pushRef, + retryOf, + }); + + await hooks.executeHookFunctions('workflowExecuteAfter', [successfulRun, {}]); + + expect(executionRepository.hardDelete).toHaveBeenCalledWith({ + workflowId, + executionId, + }); + }); + + it('should delete failed executions when error saving is disabled', async () => { + workflowData.settings = { + saveDataSuccessExecution: 'all', + saveDataErrorExecution: 'none', + }; + const hooks = getWorkflowHooksWorkerMain('webhook', executionId, workflowData, { + pushRef, + retryOf, + }); + + await hooks.executeHookFunctions('workflowExecuteAfter', [failedRun, {}]); + + expect(executionRepository.hardDelete).toHaveBeenCalledWith({ + workflowId, + executionId, + }); + }); + }); + }); + + describe('getWorkflowHooksWorkerExecuter', () => { + let hooks: WorkflowHooks; + + beforeEach(() => { + hooks = getWorkflowHooksWorkerExecuter(executionMode, executionId, workflowData, { + pushRef, + retryOf, + }); + }); + + describe('saving static data', () => { + it('should skip saving static data for manual executions', async () => { + hooks.mode = 'manual'; + + await hooks.executeHookFunctions('workflowExecuteAfter', [successfulRun, staticData]); + + expect(workflowStaticDataService.saveStaticDataById).not.toHaveBeenCalled(); + }); + + it('should save static data for prod executions', async () => { + hooks.mode = 'trigger'; + + await hooks.executeHookFunctions('workflowExecuteAfter', [successfulRun, staticData]); + + expect(workflowStaticDataService.saveStaticDataById).toHaveBeenCalledWith( + workflowId, + staticData, + ); + }); + + it('should handle static data saving errors', async () => { + hooks.mode = 'trigger'; + const error = new Error('Static data save failed'); + workflowStaticDataService.saveStaticDataById.mockRejectedValueOnce(error); + + await hooks.executeHookFunctions('workflowExecuteAfter', [successfulRun, staticData]); + + expect(errorReporter.error).toHaveBeenCalledWith(error); + }); + }); + + describe('error workflow', () => { + it('should not execute error workflow for manual executions', async () => { + hooks.mode = 'manual'; + + await hooks.executeHookFunctions('workflowExecuteAfter', [failedRun, {}]); + + expect(workflowExecutionService.executeErrorWorkflow).not.toHaveBeenCalled(); + }); + + it('should execute error workflow for failed non-manual executions', async () => { + hooks.mode = 'trigger'; + const errorWorkflow = 'error-workflow-id'; + workflowData.settings = { errorWorkflow }; + const project = mock(); + ownershipService.getWorkflowProjectCached.calledWith(workflowId).mockResolvedValue(project); + + await hooks.executeHookFunctions('workflowExecuteAfter', [failedRun, {}]); + + expect(workflowExecutionService.executeErrorWorkflow).toHaveBeenCalledWith( + errorWorkflow, + { + workflow: { + id: workflowId, + name: workflowData.name, + }, + execution: { + id: executionId, + error: expressionError, + mode: 'trigger', + retryOf, + lastNodeExecuted: undefined, + url: `http://localhost:5678/workflow/${workflowId}/executions/${executionId}`, + }, + }, + project, + ); + }); + }); + }); +}); diff --git a/packages/cli/src/execution-lifecycle-hooks/__tests__/restore-binary-data-id.test.ts b/packages/cli/src/execution-lifecycle/__tests__/restore-binary-data-id.test.ts similarity index 98% rename from packages/cli/src/execution-lifecycle-hooks/__tests__/restore-binary-data-id.test.ts rename to packages/cli/src/execution-lifecycle/__tests__/restore-binary-data-id.test.ts index f4f7a463bc..76ac0d4e21 100644 --- a/packages/cli/src/execution-lifecycle-hooks/__tests__/restore-binary-data-id.test.ts +++ b/packages/cli/src/execution-lifecycle/__tests__/restore-binary-data-id.test.ts @@ -2,7 +2,7 @@ import { BinaryDataService } from 'n8n-core'; import type { IRun } from 'n8n-workflow'; import config from '@/config'; -import { restoreBinaryDataId } from '@/execution-lifecycle-hooks/restore-binary-data-id'; +import { restoreBinaryDataId } from '@/execution-lifecycle/restore-binary-data-id'; import { mockInstance } from '@test/mocking'; function toIRun(item?: object) { diff --git a/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts b/packages/cli/src/execution-lifecycle/__tests__/save-execution-progress.test.ts similarity index 94% rename from packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts rename to packages/cli/src/execution-lifecycle/__tests__/save-execution-progress.test.ts index ac52cf3920..863006d9e7 100644 --- a/packages/cli/src/execution-lifecycle-hooks/__tests__/save-execution-progress.test.ts +++ b/packages/cli/src/execution-lifecycle/__tests__/save-execution-progress.test.ts @@ -3,11 +3,12 @@ import { Logger } from 'n8n-core'; import type { IRunExecutionData, ITaskData, IWorkflowBase } from 'n8n-workflow'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; -import { saveExecutionProgress } from '@/execution-lifecycle-hooks/save-execution-progress'; -import * as fnModule from '@/execution-lifecycle-hooks/to-save-settings'; import type { IExecutionResponse } from '@/interfaces'; import { mockInstance } from '@test/mocking'; +import { saveExecutionProgress } from '../save-execution-progress'; +import * as fnModule from '../to-save-settings'; + mockInstance(Logger); const errorReporter = mockInstance(ErrorReporter); const executionRepository = mockInstance(ExecutionRepository); diff --git a/packages/cli/src/execution-lifecycle-hooks/__tests__/to-save-settings.test.ts b/packages/cli/src/execution-lifecycle/__tests__/to-save-settings.test.ts similarity index 98% rename from packages/cli/src/execution-lifecycle-hooks/__tests__/to-save-settings.test.ts rename to packages/cli/src/execution-lifecycle/__tests__/to-save-settings.test.ts index f12c209827..142b3c34ce 100644 --- a/packages/cli/src/execution-lifecycle-hooks/__tests__/to-save-settings.test.ts +++ b/packages/cli/src/execution-lifecycle/__tests__/to-save-settings.test.ts @@ -1,5 +1,6 @@ import config from '@/config'; -import { toSaveSettings } from '@/execution-lifecycle-hooks/to-save-settings'; + +import { toSaveSettings } from '../to-save-settings'; afterEach(() => { config.load(config.default); diff --git a/packages/cli/src/execution-lifecycle/execute-error-workflow.ts b/packages/cli/src/execution-lifecycle/execute-error-workflow.ts new file mode 100644 index 0000000000..fefce8a97b --- /dev/null +++ b/packages/cli/src/execution-lifecycle/execute-error-workflow.ts @@ -0,0 +1,130 @@ +import { GlobalConfig } from '@n8n/config'; +import { Container } from '@n8n/di'; +import { ErrorReporter, Logger } from 'n8n-core'; +import type { IRun, IWorkflowBase, WorkflowExecuteMode } from 'n8n-workflow'; + +import type { IWorkflowErrorData } from '@/interfaces'; +import { OwnershipService } from '@/services/ownership.service'; +import { UrlService } from '@/services/url.service'; +import { WorkflowExecutionService } from '@/workflows/workflow-execution.service'; + +/** + * Checks if there was an error and if errorWorkflow or a trigger is defined. If so it collects + * all the data and executes it + * + * @param {IWorkflowBase} workflowData The workflow which got executed + * @param {IRun} fullRunData The run which produced the error + * @param {WorkflowExecuteMode} mode The mode in which the workflow got started in + * @param {string} [executionId] The id the execution got saved as + */ +export function executeErrorWorkflow( + workflowData: IWorkflowBase, + fullRunData: IRun, + mode: WorkflowExecuteMode, + executionId?: string, + retryOf?: string, +): void { + const logger = Container.get(Logger); + + // Check if there was an error and if so if an errorWorkflow or a trigger is set + let pastExecutionUrl: string | undefined; + if (executionId !== undefined) { + pastExecutionUrl = `${Container.get(UrlService).getWebhookBaseUrl()}workflow/${ + workflowData.id + }/executions/${executionId}`; + } + + if (fullRunData.data.resultData.error !== undefined) { + let workflowErrorData: IWorkflowErrorData; + const workflowId = workflowData.id; + + if (executionId) { + // The error did happen in an execution + workflowErrorData = { + execution: { + id: executionId, + url: pastExecutionUrl, + error: fullRunData.data.resultData.error, + lastNodeExecuted: fullRunData.data.resultData.lastNodeExecuted!, + mode, + retryOf, + }, + workflow: { + id: workflowId, + name: workflowData.name, + }, + }; + } else { + // The error did happen in a trigger + workflowErrorData = { + trigger: { + error: fullRunData.data.resultData.error, + mode, + }, + workflow: { + id: workflowId, + name: workflowData.name, + }, + }; + } + + const { errorTriggerType } = Container.get(GlobalConfig).nodes; + // Run the error workflow + // To avoid an infinite loop do not run the error workflow again if the error-workflow itself failed and it is its own error-workflow. + const { errorWorkflow } = workflowData.settings ?? {}; + if (errorWorkflow && !(mode === 'error' && workflowId && errorWorkflow === workflowId)) { + logger.debug('Start external error workflow', { + executionId, + errorWorkflowId: errorWorkflow, + workflowId, + }); + // If a specific error workflow is set run only that one + + // First, do permission checks. + if (!workflowId) { + // Manual executions do not trigger error workflows + // So this if should never happen. It was added to + // make sure there are no possible security gaps + return; + } + + Container.get(OwnershipService) + .getWorkflowProjectCached(workflowId) + .then((project) => { + void Container.get(WorkflowExecutionService).executeErrorWorkflow( + errorWorkflow, + workflowErrorData, + project, + ); + }) + .catch((error: Error) => { + Container.get(ErrorReporter).error(error); + logger.error( + `Could not execute ErrorWorkflow for execution ID ${executionId} because of error querying the workflow owner`, + { + executionId, + errorWorkflowId: errorWorkflow, + workflowId, + error, + workflowErrorData, + }, + ); + }); + } else if ( + mode !== 'error' && + workflowId !== undefined && + workflowData.nodes.some((node) => node.type === errorTriggerType) + ) { + logger.debug('Start internal error workflow', { executionId, workflowId }); + void Container.get(OwnershipService) + .getWorkflowProjectCached(workflowId) + .then((project) => { + void Container.get(WorkflowExecutionService).executeErrorWorkflow( + workflowId, + workflowErrorData, + project, + ); + }); + } + } +} diff --git a/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts b/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts new file mode 100644 index 0000000000..1296f53958 --- /dev/null +++ b/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts @@ -0,0 +1,628 @@ +import { Container } from '@n8n/di'; +import { stringify } from 'flatted'; +import { ErrorReporter, Logger, InstanceSettings } from 'n8n-core'; +import { WorkflowHooks } from 'n8n-workflow'; +import type { + IDataObject, + INode, + IRun, + IRunExecutionData, + ITaskData, + IWorkflowBase, + IWorkflowExecuteHooks, + IWorkflowHooksOptionalParameters, + WorkflowExecuteMode, + IWorkflowExecutionDataProcess, + Workflow, +} from 'n8n-workflow'; + +import { ExecutionRepository } from '@/databases/repositories/execution.repository'; +import { EventService } from '@/events/event.service'; +import { ExternalHooks } from '@/external-hooks'; +import { Push } from '@/push'; +import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; +import { isWorkflowIdValid } from '@/utils'; +import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; + +import { executeErrorWorkflow } from './execute-error-workflow'; +import { restoreBinaryDataId } from './restore-binary-data-id'; +import { saveExecutionProgress } from './save-execution-progress'; +import { + determineFinalExecutionStatus, + prepareExecutionDataForDbUpdate, + updateExistingExecution, +} from './shared/shared-hook-functions'; +import { toSaveSettings } from './to-save-settings'; + +/** + * Returns hook functions to push data to Editor-UI + */ +function hookFunctionsPush(): IWorkflowExecuteHooks { + const logger = Container.get(Logger); + const pushInstance = Container.get(Push); + return { + nodeExecuteBefore: [ + async function (this: WorkflowHooks, nodeName: string): Promise { + const { pushRef, executionId } = this; + // Push data to session which started workflow before each + // node which starts rendering + if (pushRef === undefined) { + return; + } + + logger.debug(`Executing hook on node "${nodeName}" (hookFunctionsPush)`, { + executionId, + pushRef, + workflowId: this.workflowData.id, + }); + + pushInstance.send({ type: 'nodeExecuteBefore', data: { executionId, nodeName } }, pushRef); + }, + ], + nodeExecuteAfter: [ + async function (this: WorkflowHooks, nodeName: string, data: ITaskData): Promise { + const { pushRef, executionId } = this; + // Push data to session which started workflow after each rendered node + if (pushRef === undefined) { + return; + } + + logger.debug(`Executing hook on node "${nodeName}" (hookFunctionsPush)`, { + executionId, + pushRef, + workflowId: this.workflowData.id, + }); + + pushInstance.send( + { type: 'nodeExecuteAfter', data: { executionId, nodeName, data } }, + pushRef, + ); + }, + ], + workflowExecuteBefore: [ + async function (this: WorkflowHooks, _workflow, data): Promise { + const { pushRef, executionId } = this; + const { id: workflowId, name: workflowName } = this.workflowData; + logger.debug('Executing hook (hookFunctionsPush)', { + executionId, + pushRef, + workflowId, + }); + // Push data to session which started the workflow + if (pushRef === undefined) { + return; + } + pushInstance.send( + { + type: 'executionStarted', + data: { + executionId, + mode: this.mode, + startedAt: new Date(), + retryOf: this.retryOf, + workflowId, + workflowName, + flattedRunData: data?.resultData.runData + ? stringify(data.resultData.runData) + : stringify({}), + }, + }, + pushRef, + ); + }, + ], + workflowExecuteAfter: [ + async function (this: WorkflowHooks, fullRunData: IRun): Promise { + const { pushRef, executionId } = this; + if (pushRef === undefined) return; + + const { id: workflowId } = this.workflowData; + logger.debug('Executing hook (hookFunctionsPush)', { + executionId, + pushRef, + workflowId, + }); + + const { status } = fullRunData; + if (status === 'waiting') { + pushInstance.send({ type: 'executionWaiting', data: { executionId } }, pushRef); + } else { + const rawData = stringify(fullRunData.data); + pushInstance.send( + { type: 'executionFinished', data: { executionId, workflowId, status, rawData } }, + pushRef, + ); + } + }, + ], + }; +} + +function hookFunctionsPreExecute(): IWorkflowExecuteHooks { + const externalHooks = Container.get(ExternalHooks); + return { + workflowExecuteBefore: [ + async function (this: WorkflowHooks, workflow: Workflow): Promise { + await externalHooks.run('workflow.preExecute', [workflow, this.mode]); + }, + ], + nodeExecuteAfter: [ + async function ( + this: WorkflowHooks, + nodeName: string, + data: ITaskData, + executionData: IRunExecutionData, + ): Promise { + await saveExecutionProgress( + this.workflowData, + this.executionId, + nodeName, + data, + executionData, + this.pushRef, + ); + }, + ], + }; +} + +/** + * Returns hook functions to save workflow execution and call error workflow + */ +function hookFunctionsSave(): IWorkflowExecuteHooks { + const logger = Container.get(Logger); + const workflowStatisticsService = Container.get(WorkflowStatisticsService); + const eventService = Container.get(EventService); + return { + nodeExecuteBefore: [ + async function (this: WorkflowHooks, nodeName: string): Promise { + const { executionId, workflowData: workflow } = this; + + eventService.emit('node-pre-execute', { executionId, workflow, nodeName }); + }, + ], + nodeExecuteAfter: [ + async function (this: WorkflowHooks, nodeName: string): Promise { + const { executionId, workflowData: workflow } = this; + + eventService.emit('node-post-execute', { executionId, workflow, nodeName }); + }, + ], + workflowExecuteBefore: [], + workflowExecuteAfter: [ + async function ( + this: WorkflowHooks, + fullRunData: IRun, + newStaticData: IDataObject, + ): Promise { + logger.debug('Executing hook (hookFunctionsSave)', { + executionId: this.executionId, + workflowId: this.workflowData.id, + }); + + await restoreBinaryDataId(fullRunData, this.executionId, this.mode); + + const isManualMode = this.mode === 'manual'; + + try { + if (!isManualMode && isWorkflowIdValid(this.workflowData.id) && newStaticData) { + // Workflow is saved so update in database + try { + await Container.get(WorkflowStaticDataService).saveStaticDataById( + this.workflowData.id, + newStaticData, + ); + } catch (e) { + Container.get(ErrorReporter).error(e); + logger.error( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + `There was a problem saving the workflow with id "${this.workflowData.id}" to save changed staticData: "${e.message}" (hookFunctionsSave)`, + { executionId: this.executionId, workflowId: this.workflowData.id }, + ); + } + } + + const executionStatus = determineFinalExecutionStatus(fullRunData); + fullRunData.status = executionStatus; + + const saveSettings = toSaveSettings(this.workflowData.settings); + + if (isManualMode && !saveSettings.manual && !fullRunData.waitTill) { + /** + * When manual executions are not being saved, we only soft-delete + * the execution so that the user can access its binary data + * while building their workflow. + * + * The manual execution and its binary data will be hard-deleted + * on the next pruning cycle after the grace period set by + * `EXECUTIONS_DATA_HARD_DELETE_BUFFER`. + */ + await Container.get(ExecutionRepository).softDelete(this.executionId); + + return; + } + + const shouldNotSave = + (executionStatus === 'success' && !saveSettings.success) || + (executionStatus !== 'success' && !saveSettings.error); + + if (shouldNotSave && !fullRunData.waitTill && !isManualMode) { + executeErrorWorkflow( + this.workflowData, + fullRunData, + this.mode, + this.executionId, + this.retryOf, + ); + + await Container.get(ExecutionRepository).hardDelete({ + workflowId: this.workflowData.id, + executionId: this.executionId, + }); + + return; + } + + // Although it is treated as IWorkflowBase here, it's being instantiated elsewhere with properties that may be sensitive + // As a result, we should create an IWorkflowBase object with only the data we want to save in it. + const fullExecutionData = prepareExecutionDataForDbUpdate({ + runData: fullRunData, + workflowData: this.workflowData, + workflowStatusFinal: executionStatus, + retryOf: this.retryOf, + }); + + // When going into the waiting state, store the pushRef in the execution-data + if (fullRunData.waitTill && isManualMode) { + fullExecutionData.data.pushRef = this.pushRef; + } + + await updateExistingExecution({ + executionId: this.executionId, + workflowId: this.workflowData.id, + executionData: fullExecutionData, + }); + + if (!isManualMode) { + executeErrorWorkflow( + this.workflowData, + fullRunData, + this.mode, + this.executionId, + this.retryOf, + ); + } + } catch (error) { + Container.get(ErrorReporter).error(error); + logger.error(`Failed saving execution data to DB on execution ID ${this.executionId}`, { + executionId: this.executionId, + workflowId: this.workflowData.id, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + error, + }); + if (!isManualMode) { + executeErrorWorkflow( + this.workflowData, + fullRunData, + this.mode, + this.executionId, + this.retryOf, + ); + } + } finally { + workflowStatisticsService.emit('workflowExecutionCompleted', { + workflowData: this.workflowData, + fullRunData, + }); + } + }, + ], + nodeFetchedData: [ + async (workflowId: string, node: INode) => { + workflowStatisticsService.emit('nodeFetchedData', { workflowId, node }); + }, + ], + }; +} + +/** + * Returns hook functions to save workflow execution and call error workflow + * for running with queues. Manual executions should never run on queues as + * they are always executed in the main process. + */ +function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { + const logger = Container.get(Logger); + const workflowStatisticsService = Container.get(WorkflowStatisticsService); + const eventService = Container.get(EventService); + return { + nodeExecuteBefore: [ + async function (this: WorkflowHooks, nodeName: string): Promise { + const { executionId, workflowData: workflow } = this; + + eventService.emit('node-pre-execute', { executionId, workflow, nodeName }); + }, + ], + nodeExecuteAfter: [ + async function (this: WorkflowHooks, nodeName: string): Promise { + const { executionId, workflowData: workflow } = this; + + eventService.emit('node-post-execute', { executionId, workflow, nodeName }); + }, + ], + workflowExecuteBefore: [ + async function (this: WorkflowHooks): Promise { + const { executionId, workflowData } = this; + + eventService.emit('workflow-pre-execute', { executionId, data: workflowData }); + }, + ], + workflowExecuteAfter: [ + async function ( + this: WorkflowHooks, + fullRunData: IRun, + newStaticData: IDataObject, + ): Promise { + logger.debug('Executing hook (hookFunctionsSaveWorker)', { + executionId: this.executionId, + workflowId: this.workflowData.id, + }); + + const isManualMode = this.mode === 'manual'; + + try { + if (!isManualMode && isWorkflowIdValid(this.workflowData.id) && newStaticData) { + // Workflow is saved so update in database + try { + await Container.get(WorkflowStaticDataService).saveStaticDataById( + this.workflowData.id, + newStaticData, + ); + } catch (e) { + Container.get(ErrorReporter).error(e); + logger.error( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + `There was a problem saving the workflow with id "${this.workflowData.id}" to save changed staticData: "${e.message}" (workflowExecuteAfter)`, + { pushRef: this.pushRef, workflowId: this.workflowData.id }, + ); + } + } + + const workflowStatusFinal = determineFinalExecutionStatus(fullRunData); + fullRunData.status = workflowStatusFinal; + + if ( + !isManualMode && + workflowStatusFinal !== 'success' && + workflowStatusFinal !== 'waiting' + ) { + executeErrorWorkflow( + this.workflowData, + fullRunData, + this.mode, + this.executionId, + this.retryOf, + ); + } + + // Although it is treated as IWorkflowBase here, it's being instantiated elsewhere with properties that may be sensitive + // As a result, we should create an IWorkflowBase object with only the data we want to save in it. + const fullExecutionData = prepareExecutionDataForDbUpdate({ + runData: fullRunData, + workflowData: this.workflowData, + workflowStatusFinal, + retryOf: this.retryOf, + }); + + // When going into the waiting state, store the pushRef in the execution-data + if (fullRunData.waitTill && isManualMode) { + fullExecutionData.data.pushRef = this.pushRef; + } + + await updateExistingExecution({ + executionId: this.executionId, + workflowId: this.workflowData.id, + executionData: fullExecutionData, + }); + } catch (error) { + if (!isManualMode) { + executeErrorWorkflow( + this.workflowData, + fullRunData, + this.mode, + this.executionId, + this.retryOf, + ); + } + } finally { + workflowStatisticsService.emit('workflowExecutionCompleted', { + workflowData: this.workflowData, + fullRunData, + }); + } + }, + async function (this: WorkflowHooks, runData: IRun): Promise { + const { executionId, workflowData: workflow } = this; + + eventService.emit('workflow-post-execute', { + workflow, + executionId, + runData, + }); + }, + async function (this: WorkflowHooks, fullRunData: IRun) { + const externalHooks = Container.get(ExternalHooks); + if (externalHooks.exists('workflow.postExecute')) { + try { + await externalHooks.run('workflow.postExecute', [ + fullRunData, + this.workflowData, + this.executionId, + ]); + } catch (error) { + Container.get(ErrorReporter).error(error); + Container.get(Logger).error( + 'There was a problem running hook "workflow.postExecute"', + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + error, + ); + } + } + }, + ], + nodeFetchedData: [ + async (workflowId: string, node: INode) => { + workflowStatisticsService.emit('nodeFetchedData', { workflowId, node }); + }, + ], + }; +} + +/** + * Returns WorkflowHooks instance for running integrated workflows + * (Workflows which get started inside of another workflow) + */ +export function getWorkflowHooksIntegrated( + mode: WorkflowExecuteMode, + executionId: string, + workflowData: IWorkflowBase, +): WorkflowHooks { + const hookFunctions = hookFunctionsSave(); + const preExecuteFunctions = hookFunctionsPreExecute(); + for (const key of Object.keys(preExecuteFunctions)) { + const hooks = hookFunctions[key] ?? []; + hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]); + } + return new WorkflowHooks(hookFunctions, mode, executionId, workflowData); +} + +/** + * Returns WorkflowHooks instance for worker in scaling mode. + */ +export function getWorkflowHooksWorkerExecuter( + mode: WorkflowExecuteMode, + executionId: string, + workflowData: IWorkflowBase, + optionalParameters?: IWorkflowHooksOptionalParameters, +): WorkflowHooks { + optionalParameters = optionalParameters || {}; + const hookFunctions = hookFunctionsSaveWorker(); + const preExecuteFunctions = hookFunctionsPreExecute(); + for (const key of Object.keys(preExecuteFunctions)) { + const hooks = hookFunctions[key] ?? []; + hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]); + } + + if (mode === 'manual' && Container.get(InstanceSettings).isWorker) { + const pushHooks = hookFunctionsPush(); + for (const key of Object.keys(pushHooks)) { + if (hookFunctions[key] === undefined) { + hookFunctions[key] = []; + } + // eslint-disable-next-line prefer-spread + hookFunctions[key].push.apply(hookFunctions[key], pushHooks[key]); + } + } + + return new WorkflowHooks(hookFunctions, mode, executionId, workflowData, optionalParameters); +} + +/** + * Returns WorkflowHooks instance for main process if workflow runs via worker + */ +export function getWorkflowHooksWorkerMain( + mode: WorkflowExecuteMode, + executionId: string, + workflowData: IWorkflowBase, + optionalParameters?: IWorkflowHooksOptionalParameters, +): WorkflowHooks { + optionalParameters = optionalParameters || {}; + const hookFunctions = hookFunctionsPreExecute(); + + // TODO: why are workers pushing to frontend? + // TODO: simplifying this for now to just leave the bare minimum hooks + + // const hookFunctions = hookFunctionsPush(); + // const preExecuteFunctions = hookFunctionsPreExecute(); + // for (const key of Object.keys(preExecuteFunctions)) { + // if (hookFunctions[key] === undefined) { + // hookFunctions[key] = []; + // } + // hookFunctions[key]!.push.apply(hookFunctions[key], preExecuteFunctions[key]); + // } + + // When running with worker mode, main process executes + // Only workflowExecuteBefore + workflowExecuteAfter + // So to avoid confusion, we are removing other hooks. + hookFunctions.nodeExecuteBefore = []; + hookFunctions.nodeExecuteAfter = []; + hookFunctions.workflowExecuteAfter = [ + async function (this: WorkflowHooks, fullRunData: IRun): Promise { + // Don't delete executions before they are finished + if (!fullRunData.finished) return; + + const executionStatus = determineFinalExecutionStatus(fullRunData); + fullRunData.status = executionStatus; + + const saveSettings = toSaveSettings(this.workflowData.settings); + + const isManualMode = this.mode === 'manual'; + + if (isManualMode && !saveSettings.manual && !fullRunData.waitTill) { + /** + * When manual executions are not being saved, we only soft-delete + * the execution so that the user can access its binary data + * while building their workflow. + * + * The manual execution and its binary data will be hard-deleted + * on the next pruning cycle after the grace period set by + * `EXECUTIONS_DATA_HARD_DELETE_BUFFER`. + */ + await Container.get(ExecutionRepository).softDelete(this.executionId); + + return; + } + + const shouldNotSave = + (executionStatus === 'success' && !saveSettings.success) || + (executionStatus !== 'success' && !saveSettings.error); + + if (!isManualMode && shouldNotSave && !fullRunData.waitTill) { + await Container.get(ExecutionRepository).hardDelete({ + workflowId: this.workflowData.id, + executionId: this.executionId, + }); + } + }, + ]; + + return new WorkflowHooks(hookFunctions, mode, executionId, workflowData, optionalParameters); +} + +/** + * Returns WorkflowHooks instance for running the main workflow + */ +export function getWorkflowHooksMain( + data: IWorkflowExecutionDataProcess, + executionId: string, +): WorkflowHooks { + const hookFunctions = hookFunctionsSave(); + const pushFunctions = hookFunctionsPush(); + for (const key of Object.keys(pushFunctions)) { + const hooks = hookFunctions[key] ?? []; + hooks.push.apply(hookFunctions[key], pushFunctions[key]); + } + + const preExecuteFunctions = hookFunctionsPreExecute(); + for (const key of Object.keys(preExecuteFunctions)) { + const hooks = hookFunctions[key] ?? []; + hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]); + } + + if (!hookFunctions.nodeExecuteBefore) hookFunctions.nodeExecuteBefore = []; + if (!hookFunctions.nodeExecuteAfter) hookFunctions.nodeExecuteAfter = []; + + return new WorkflowHooks(hookFunctions, data.executionMode, executionId, data.workflowData, { + pushRef: data.pushRef, + retryOf: data.retryOf as string, + }); +} diff --git a/packages/cli/src/execution-lifecycle-hooks/restore-binary-data-id.ts b/packages/cli/src/execution-lifecycle/restore-binary-data-id.ts similarity index 100% rename from packages/cli/src/execution-lifecycle-hooks/restore-binary-data-id.ts rename to packages/cli/src/execution-lifecycle/restore-binary-data-id.ts diff --git a/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts b/packages/cli/src/execution-lifecycle/save-execution-progress.ts similarity index 97% rename from packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts rename to packages/cli/src/execution-lifecycle/save-execution-progress.ts index 9e751c90f6..8ca33c7095 100644 --- a/packages/cli/src/execution-lifecycle-hooks/save-execution-progress.ts +++ b/packages/cli/src/execution-lifecycle/save-execution-progress.ts @@ -3,7 +3,8 @@ import { ErrorReporter, Logger } from 'n8n-core'; import type { IRunExecutionData, ITaskData, IWorkflowBase } from 'n8n-workflow'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; -import { toSaveSettings } from '@/execution-lifecycle-hooks/to-save-settings'; + +import { toSaveSettings } from './to-save-settings'; export async function saveExecutionProgress( workflowData: IWorkflowBase, diff --git a/packages/cli/src/execution-lifecycle-hooks/shared/__tests__/shared-hook-functions.test.ts b/packages/cli/src/execution-lifecycle/shared/__tests__/shared-hook-functions.test.ts similarity index 100% rename from packages/cli/src/execution-lifecycle-hooks/shared/__tests__/shared-hook-functions.test.ts rename to packages/cli/src/execution-lifecycle/shared/__tests__/shared-hook-functions.test.ts diff --git a/packages/cli/src/execution-lifecycle-hooks/shared/shared-hook-functions.ts b/packages/cli/src/execution-lifecycle/shared/shared-hook-functions.ts similarity index 100% rename from packages/cli/src/execution-lifecycle-hooks/shared/shared-hook-functions.ts rename to packages/cli/src/execution-lifecycle/shared/shared-hook-functions.ts diff --git a/packages/cli/src/execution-lifecycle-hooks/to-save-settings.ts b/packages/cli/src/execution-lifecycle/to-save-settings.ts similarity index 100% rename from packages/cli/src/execution-lifecycle-hooks/to-save-settings.ts rename to packages/cli/src/execution-lifecycle/to-save-settings.ts diff --git a/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts b/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts index 9cb681a7ef..20abce70cd 100644 --- a/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts +++ b/packages/cli/src/executions/__tests__/execution-recovery.service.test.ts @@ -3,6 +3,7 @@ import { stringify } from 'flatted'; import { mock } from 'jest-mock-extended'; import { InstanceSettings } from 'n8n-core'; import { randomInt } from 'n8n-workflow'; +import assert from 'node:assert'; import { ARTIFICIAL_TASK_DATA } from '@/constants'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; @@ -127,12 +128,15 @@ describe('ExecutionRecoveryService', () => { }); describe('if leader, with 1+ messages', () => { - test('should return `null` if execution succeeded', async () => { + test('for successful dataful execution, should return `null`', async () => { /** * Arrange */ const workflow = await createWorkflow(); - const execution = await createExecution({ status: 'success' }, workflow); + const execution = await createExecution( + { status: 'success', data: stringify({ runData: { foo: 'bar' } }) }, + workflow, + ); const messages = setupMessages(execution.id, 'Some workflow'); /** @@ -170,7 +174,38 @@ describe('ExecutionRecoveryService', () => { expect(amendedExecution).toBeNull(); }); - test('should update `status`, `stoppedAt` and `data` if last node did not finish', async () => { + test('for successful dataless execution, should update `status`, `stoppedAt` and `data`', async () => { + /** + * Arrange + */ + const workflow = await createWorkflow(); + const execution = await createExecution( + { + status: 'success', + data: stringify(undefined), // saved execution but likely crashed while saving high-volume data + }, + workflow, + ); + const messages = setupMessages(execution.id, 'Some workflow'); + + /** + * Act + */ + const amendedExecution = await executionRecoveryService.recoverFromLogs( + execution.id, + messages, + ); + + /** + * Assert + */ + assert(amendedExecution); + expect(amendedExecution.stoppedAt).not.toBe(execution.stoppedAt); + expect(amendedExecution.data).toEqual({ resultData: { runData: {} } }); + expect(amendedExecution.status).toBe('crashed'); + }); + + test('for running execution, should update `status`, `stoppedAt` and `data` if last node did not finish', async () => { /** * Arrange */ diff --git a/packages/cli/src/executions/execution-recovery.service.ts b/packages/cli/src/executions/execution-recovery.service.ts index 503e53d023..f3cae91967 100644 --- a/packages/cli/src/executions/execution-recovery.service.ts +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -9,9 +9,9 @@ import { ExecutionRepository } from '@/databases/repositories/execution.reposito import { NodeCrashedError } from '@/errors/node-crashed.error'; import { WorkflowCrashedError } from '@/errors/workflow-crashed.error'; import { EventService } from '@/events/event.service'; +import { getWorkflowHooksMain } from '@/execution-lifecycle/execution-lifecycle-hooks'; import type { IExecutionResponse } from '@/interfaces'; import { Push } from '@/push'; -import { getWorkflowHooksMain } from '@/workflow-execute-additional-data'; // @TODO: Dependency cycle import type { EventMessageTypes } from '../eventbus/event-message-classes'; @@ -73,7 +73,7 @@ export class ExecutionRecoveryService { unflattenData: true, }); - if (!execution || execution.status === 'success') return null; + if (!execution || (execution.status === 'success' && execution.data)) return null; const runExecutionData = execution.data ?? { resultData: { runData: {} } }; diff --git a/packages/cli/src/license.ts b/packages/cli/src/license.ts index ed9b66a1a6..0b02e7da1a 100644 --- a/packages/cli/src/license.ts +++ b/packages/cli/src/license.ts @@ -43,7 +43,10 @@ export class License { this.logger = this.logger.scoped('license'); } - async init(forceRecreate = false) { + async init({ + forceRecreate = false, + isCli = false, + }: { forceRecreate?: boolean; isCli?: boolean } = {}) { if (this.manager && !forceRecreate) { this.logger.warn('License manager already initialized or shutting down'); return; @@ -73,10 +76,13 @@ export class License { const { isLeader } = this.instanceSettings; const { autoRenewalEnabled } = this.globalConfig.license; + const eligibleToRenew = isCli || isLeader; - const shouldRenew = isLeader && autoRenewalEnabled; + const shouldRenew = eligibleToRenew && autoRenewalEnabled; - if (isLeader && !autoRenewalEnabled) this.logger.warn(LICENSE_RENEWAL_DISABLED_WARNING); + if (eligibleToRenew && !autoRenewalEnabled) { + this.logger.warn(LICENSE_RENEWAL_DISABLED_WARNING); + } try { this.manager = new LicenseManager({ @@ -390,7 +396,7 @@ export class License { async reinit() { this.manager?.reset(); - await this.init(true); + await this.init({ forceRecreate: true }); this.logger.debug('License reinitialized'); } } diff --git a/packages/cli/src/load-nodes-and-credentials.ts b/packages/cli/src/load-nodes-and-credentials.ts index b027192388..88cbfc05cb 100644 --- a/packages/cli/src/load-nodes-and-credentials.ts +++ b/packages/cli/src/load-nodes-and-credentials.ts @@ -323,7 +323,15 @@ export class LoadNodesAndCredentials { name: `${packageName}.${name}`, })), ); - this.types.credentials = this.types.credentials.concat(types.credentials); + this.types.credentials = this.types.credentials.concat( + types.credentials.map(({ supportedNodes, ...rest }) => ({ + ...rest, + supportedNodes: + loader instanceof PackageDirectoryLoader + ? supportedNodes?.map((nodeName) => `${loader.packageName}.${nodeName}`) + : undefined, + })), + ); // Nodes and credentials that have been loaded immediately for (const nodeTypeName in loader.nodeTypes) { diff --git a/packages/cli/src/metrics/license-metrics.service.ts b/packages/cli/src/metrics/license-metrics.service.ts index bd4ca6055b..1531809cd2 100644 --- a/packages/cli/src/metrics/license-metrics.service.ts +++ b/packages/cli/src/metrics/license-metrics.service.ts @@ -37,7 +37,9 @@ export class LicenseMetricsService { async collectPassthroughData() { return { - activeWorkflowIds: await this.workflowRepository.getActiveIds(), + // Get only the first 1000 active workflow IDs to avoid sending too much data to License Server + // Passthrough data is forwarded to Telemetry for further analysis, such as quota excesses + activeWorkflowIds: await this.workflowRepository.getActiveIds({ maxResults: 1000 }), }; } } diff --git a/packages/cli/src/public-api/v1/handlers/source-control/source-control.handler.ts b/packages/cli/src/public-api/v1/handlers/source-control/source-control.handler.ts index 09f5a1bc85..4a0729ef6e 100644 --- a/packages/cli/src/public-api/v1/handlers/source-control/source-control.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/source-control/source-control.handler.ts @@ -36,7 +36,7 @@ export = { try { const payload = PullWorkFolderRequestDto.parse(req.body); const sourceControlService = Container.get(SourceControlService); - const result = await sourceControlService.pullWorkfolder(req.user.id, payload); + const result = await sourceControlService.pullWorkfolder(req.user, payload); if (result.statusCode === 200) { Container.get(EventService).emit('source-control-user-pulled-api', { diff --git a/packages/cli/src/push/index.ts b/packages/cli/src/push/index.ts index e795df5722..4c2d8ac032 100644 --- a/packages/cli/src/push/index.ts +++ b/packages/cli/src/push/index.ts @@ -169,8 +169,12 @@ export class Push extends TypedEmitter { this.logger.warn(`Size of "${type}" (${eventMb} MB) exceeds max size ${maxMb} MB. Trimming...`); - if (type === 'nodeExecuteAfter') pushMsgCopy.data.data.data = TRIMMED_TASK_DATA_CONNECTIONS; - else if (type === 'executionFinished') pushMsgCopy.data.rawData = ''; // prompt client to fetch from DB + if (type === 'nodeExecuteAfter') { + pushMsgCopy.data.itemCount = pushMsgCopy.data.data.data?.main[0]?.length ?? 1; + pushMsgCopy.data.data.data = TRIMMED_TASK_DATA_CONNECTIONS; + } else if (type === 'executionFinished') { + pushMsgCopy.data.rawData = ''; // prompt client to fetch from DB + } void this.publisher.publishCommand({ command: 'relay-execution-lifecycle-event', diff --git a/packages/cli/src/scaling/job-processor.ts b/packages/cli/src/scaling/job-processor.ts index 2aff0787c4..84657f52a0 100644 --- a/packages/cli/src/scaling/job-processor.ts +++ b/packages/cli/src/scaling/job-processor.ts @@ -1,6 +1,12 @@ import type { RunningJobSummary } from '@n8n/api-types'; import { Service } from '@n8n/di'; -import { InstanceSettings, WorkflowExecute, ErrorReporter, Logger } from 'n8n-core'; +import { + WorkflowHasIssuesError, + InstanceSettings, + WorkflowExecute, + ErrorReporter, + Logger, +} from 'n8n-core'; import type { ExecutionStatus, IExecuteResponsePromiseData, @@ -13,6 +19,7 @@ import type PCancelable from 'p-cancelable'; import config from '@/config'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import { getWorkflowHooksWorkerExecuter } from '@/execution-lifecycle/execution-lifecycle-hooks'; import { ManualExecutionService } from '@/manual-execution.service'; import { NodeTypes } from '@/node-types'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; @@ -124,7 +131,7 @@ export class JobProcessor { const { pushRef } = job.data; - additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter( + additionalData.hooks = getWorkflowHooksWorkerExecuter( execution.mode, job.data.executionId, execution.workflowData, @@ -177,13 +184,33 @@ export class JobProcessor { userId: manualData?.userId, }; - workflowRun = this.manualExecutionService.runManually( - data, - workflow, - additionalData, - executionId, - resultData.pinData, - ); + try { + workflowRun = this.manualExecutionService.runManually( + data, + workflow, + additionalData, + executionId, + resultData.pinData, + ); + } catch (error) { + if (error instanceof WorkflowHasIssuesError) { + // execution did not even start, but we call `workflowExecuteAfter` to notify main + + const now = new Date(); + const runData: IRun = { + mode: 'manual', + status: 'error', + finished: false, + startedAt: now, + stoppedAt: now, + data: { resultData: { error, runData: {} } }, + }; + + await additionalData.hooks.executeHookFunctions('workflowExecuteAfter', [runData]); + return { success: false }; + } + throw error; + } } else if (execution.data !== undefined) { workflowExecute = new WorkflowExecute(additionalData, execution.mode, execution.data); workflowRun = workflowExecute.processRunExecutionData(workflow); diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index 43605939a3..9eb3f8efa1 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -130,7 +130,7 @@ export class ProjectService { ); } else { for (const sharedCredential of ownedCredentials) { - await credentialsService.delete(sharedCredential.credentials); + await credentialsService.delete(user, sharedCredential.credentials.id); } } diff --git a/packages/cli/src/task-runners/__tests__/task-runner-process.test.ts b/packages/cli/src/task-runners/__tests__/task-runner-process.test.ts index a3ca65fe21..7bf475bf7c 100644 --- a/packages/cli/src/task-runners/__tests__/task-runner-process.test.ts +++ b/packages/cli/src/task-runners/__tests__/task-runner-process.test.ts @@ -118,13 +118,14 @@ describe('TaskRunnerProcess', () => { expect(options.env).not.toHaveProperty('NODE_OPTIONS'); }); - it('should use --disallow-code-generation-from-strings flag', async () => { + it('should use --disallow-code-generation-from-strings and --disable-proto=delete flags', async () => { jest.spyOn(authService, 'createGrantToken').mockResolvedValue('grantToken'); await taskRunnerProcess.start(); expect(spawnMock.mock.calls[0].at(1)).toEqual([ '--disallow-code-generation-from-strings', + '--disable-proto=delete', expect.stringContaining('/packages/@n8n/task-runner/dist/start.js'), ]); }); diff --git a/packages/cli/src/task-runners/task-runner-process.ts b/packages/cli/src/task-runners/task-runner-process.ts index 7e83e56ca0..c2e769e6ec 100644 --- a/packages/cli/src/task-runners/task-runner-process.ts +++ b/packages/cli/src/task-runners/task-runner-process.ts @@ -54,6 +54,7 @@ export class TaskRunnerProcess extends TypedEmitter { private readonly passthroughEnvVars = [ 'PATH', + 'HOME', // So home directory can be resolved correctly 'GENERIC_TIMEZONE', 'NODE_FUNCTION_ALLOW_BUILTIN', 'NODE_FUNCTION_ALLOW_EXTERNAL', @@ -106,9 +107,13 @@ export class TaskRunnerProcess extends TypedEmitter { startNode(grantToken: string, taskBrokerUri: string) { const startScript = require.resolve('@n8n/task-runner/start'); - return spawn('node', ['--disallow-code-generation-from-strings', startScript], { - env: this.getProcessEnvVars(grantToken, taskBrokerUri), - }); + return spawn( + 'node', + ['--disallow-code-generation-from-strings', '--disable-proto=delete', startScript], + { + env: this.getProcessEnvVars(grantToken, taskBrokerUri), + }, + ); } @OnShutdown() diff --git a/packages/cli/src/task-runners/task-runner-server.ts b/packages/cli/src/task-runners/task-runner-server.ts index 80679f8c41..cadfd7aadd 100644 --- a/packages/cli/src/task-runners/task-runner-server.ts +++ b/packages/cli/src/task-runners/task-runner-server.ts @@ -100,7 +100,7 @@ export class TaskRunnerServer { this.server.on('error', (error: Error & { code: string }) => { if (error.code === 'EADDRINUSE') { this.logger.info( - `n8n Task Runner's port ${port} is already in use. Do you have another instance of n8n running already?`, + `n8n Task Broker's port ${port} is already in use. Do you have another instance of n8n running already?`, ); process.exit(1); } @@ -111,7 +111,7 @@ export class TaskRunnerServer { this.server.listen(port, address, () => resolve()); }); - this.logger.info(`n8n Task Runner server ready on ${address}, port ${port}`); + this.logger.info(`n8n Task Broker ready on ${address}, port ${port}`); } /** Creates WebSocket server for handling upgrade requests */ diff --git a/packages/cli/src/__tests__/object-to-error.test.ts b/packages/cli/src/utils/__tests__/object-to-error.test.ts similarity index 94% rename from packages/cli/src/__tests__/object-to-error.test.ts rename to packages/cli/src/utils/__tests__/object-to-error.test.ts index 311f4dce55..c65676a426 100644 --- a/packages/cli/src/__tests__/object-to-error.test.ts +++ b/packages/cli/src/utils/__tests__/object-to-error.test.ts @@ -2,7 +2,7 @@ import { mock } from 'jest-mock-extended'; import type { INode } from 'n8n-workflow'; import { NodeOperationError, type Workflow } from 'n8n-workflow'; -import { objectToError } from '../workflow-execute-additional-data'; +import { objectToError } from '../object-to-error'; describe('objectToError', () => { describe('node error handling', () => { diff --git a/packages/cli/src/utils/object-to-error.ts b/packages/cli/src/utils/object-to-error.ts new file mode 100644 index 0000000000..ffb0cd8fb3 --- /dev/null +++ b/packages/cli/src/utils/object-to-error.ts @@ -0,0 +1,53 @@ +import { isObjectLiteral } from 'n8n-core'; +import { NodeOperationError } from 'n8n-workflow'; +import type { Workflow } from 'n8n-workflow'; + +export function objectToError(errorObject: unknown, workflow: Workflow): Error { + // TODO: Expand with other error types + if (errorObject instanceof Error) { + // If it's already an Error instance, return it as is. + return errorObject; + } else if ( + isObjectLiteral(errorObject) && + 'message' in errorObject && + typeof errorObject.message === 'string' + ) { + // If it's an object with a 'message' property, create a new Error instance. + let error: Error | undefined; + if ( + 'node' in errorObject && + isObjectLiteral(errorObject.node) && + typeof errorObject.node.name === 'string' + ) { + const node = workflow.getNode(errorObject.node.name); + + if (node) { + error = new NodeOperationError( + node, + errorObject as unknown as Error, + errorObject as object, + ); + } + } + + if (error === undefined) { + error = new Error(errorObject.message); + } + + if ('description' in errorObject) { + // @ts-expect-error Error descriptions are surfaced by the UI but + // not all backend errors account for this property yet. + error.description = errorObject.description as string; + } + + if ('stack' in errorObject) { + // If there's a 'stack' property, set it on the new Error instance. + error.stack = errorObject.stack as string; + } + + return error; + } else { + // If it's neither an Error nor an object with a 'message' property, create a generic Error. + return new Error('An error occurred'); + } +} diff --git a/packages/cli/src/webhooks/webhook-request-handler.ts b/packages/cli/src/webhooks/webhook-request-handler.ts index 96cf6da745..8eb62358b1 100644 --- a/packages/cli/src/webhooks/webhook-request-handler.ts +++ b/packages/cli/src/webhooks/webhook-request-handler.ts @@ -1,5 +1,7 @@ +import { Container } from '@n8n/di'; import type express from 'express'; -import type { IHttpRequestMethods } from 'n8n-workflow'; +import { Logger } from 'n8n-core'; +import { ensureError, type IHttpRequestMethods } from 'n8n-workflow'; import * as ResponseHelper from '@/response-helper'; import type { @@ -52,8 +54,14 @@ class WebhookRequestHandler { response.headers, ); } - } catch (error) { - return ResponseHelper.sendErrorResponse(res, error as Error); + } catch (e) { + const error = ensureError(e); + Container.get(Logger).debug( + `Error in handling webhook request ${req.method} ${req.path}: ${error.message}`, + { stacktrace: error.stack }, + ); + + return ResponseHelper.sendErrorResponse(res, error); } } diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index f559765ec3..c3b3ed8693 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -5,15 +5,8 @@ import type { PushMessage, PushType } from '@n8n/api-types'; import { GlobalConfig } from '@n8n/config'; import { Container } from '@n8n/di'; -import { stringify } from 'flatted'; -import { - ErrorReporter, - Logger, - InstanceSettings, - WorkflowExecute, - isObjectLiteral, -} from 'n8n-core'; -import { ApplicationError, NodeOperationError, Workflow, WorkflowHooks } from 'n8n-workflow'; +import { Logger, WorkflowExecute } from 'n8n-core'; +import { ApplicationError, Workflow } from 'n8n-workflow'; import type { IDataObject, IExecuteData, @@ -23,11 +16,8 @@ import type { INodeParameters, IRun, IRunExecutionData, - ITaskData, IWorkflowBase, IWorkflowExecuteAdditionalData, - IWorkflowExecuteHooks, - IWorkflowHooksOptionalParameters, IWorkflowSettings, WorkflowExecuteMode, ExecutionStatus, @@ -44,633 +34,23 @@ import type { import { ActiveExecutions } from '@/active-executions'; import { CredentialsHelper } from '@/credentials-helper'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import { EventService } from '@/events/event.service'; import type { AiEventMap, AiEventPayload } from '@/events/maps/ai.event-map'; +import { getWorkflowHooksIntegrated } from '@/execution-lifecycle/execution-lifecycle-hooks'; import { ExternalHooks } from '@/external-hooks'; -import type { IWorkflowErrorData, UpdateExecutionPayload } from '@/interfaces'; +import type { UpdateExecutionPayload } from '@/interfaces'; import { NodeTypes } from '@/node-types'; import { Push } from '@/push'; -import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; -import { findSubworkflowStart, isWorkflowIdValid } from '@/utils'; +import { SecretsHelper } from '@/secrets-helpers.ee'; +import { UrlService } from '@/services/url.service'; +import { SubworkflowPolicyChecker } from '@/subworkflows/subworkflow-policy-checker.service'; +import { TaskRequester } from '@/task-runners/task-managers/task-requester'; +import { PermissionChecker } from '@/user-management/permission-checker'; +import { findSubworkflowStart } from '@/utils'; +import { objectToError } from '@/utils/object-to-error'; import * as WorkflowHelpers from '@/workflow-helpers'; -import { WorkflowRepository } from './databases/repositories/workflow.repository'; -import { EventService } from './events/event.service'; -import { restoreBinaryDataId } from './execution-lifecycle-hooks/restore-binary-data-id'; -import { saveExecutionProgress } from './execution-lifecycle-hooks/save-execution-progress'; -import { - determineFinalExecutionStatus, - prepareExecutionDataForDbUpdate, - updateExistingExecution, -} from './execution-lifecycle-hooks/shared/shared-hook-functions'; -import { toSaveSettings } from './execution-lifecycle-hooks/to-save-settings'; -import { SecretsHelper } from './secrets-helpers.ee'; -import { OwnershipService } from './services/ownership.service'; -import { UrlService } from './services/url.service'; -import { SubworkflowPolicyChecker } from './subworkflows/subworkflow-policy-checker.service'; -import { TaskRequester } from './task-runners/task-managers/task-requester'; -import { PermissionChecker } from './user-management/permission-checker'; -import { WorkflowExecutionService } from './workflows/workflow-execution.service'; -import { WorkflowStaticDataService } from './workflows/workflow-static-data.service'; - -export function objectToError(errorObject: unknown, workflow: Workflow): Error { - // TODO: Expand with other error types - if (errorObject instanceof Error) { - // If it's already an Error instance, return it as is. - return errorObject; - } else if ( - isObjectLiteral(errorObject) && - 'message' in errorObject && - typeof errorObject.message === 'string' - ) { - // If it's an object with a 'message' property, create a new Error instance. - let error: Error | undefined; - if ( - 'node' in errorObject && - isObjectLiteral(errorObject.node) && - typeof errorObject.node.name === 'string' - ) { - const node = workflow.getNode(errorObject.node.name); - - if (node) { - error = new NodeOperationError( - node, - errorObject as unknown as Error, - errorObject as object, - ); - } - } - - if (error === undefined) { - error = new Error(errorObject.message); - } - - if ('description' in errorObject) { - // @ts-expect-error Error descriptions are surfaced by the UI but - // not all backend errors account for this property yet. - error.description = errorObject.description as string; - } - - if ('stack' in errorObject) { - // If there's a 'stack' property, set it on the new Error instance. - error.stack = errorObject.stack as string; - } - - return error; - } else { - // If it's neither an Error nor an object with a 'message' property, create a generic Error. - return new Error('An error occurred'); - } -} - -/** - * Checks if there was an error and if errorWorkflow or a trigger is defined. If so it collects - * all the data and executes it - * - * @param {IWorkflowBase} workflowData The workflow which got executed - * @param {IRun} fullRunData The run which produced the error - * @param {WorkflowExecuteMode} mode The mode in which the workflow got started in - * @param {string} [executionId] The id the execution got saved as - */ -export function executeErrorWorkflow( - workflowData: IWorkflowBase, - fullRunData: IRun, - mode: WorkflowExecuteMode, - executionId?: string, - retryOf?: string, -): void { - const logger = Container.get(Logger); - - // Check if there was an error and if so if an errorWorkflow or a trigger is set - let pastExecutionUrl: string | undefined; - if (executionId !== undefined) { - pastExecutionUrl = `${Container.get(UrlService).getWebhookBaseUrl()}workflow/${ - workflowData.id - }/executions/${executionId}`; - } - - if (fullRunData.data.resultData.error !== undefined) { - let workflowErrorData: IWorkflowErrorData; - const workflowId = workflowData.id; - - if (executionId) { - // The error did happen in an execution - workflowErrorData = { - execution: { - id: executionId, - url: pastExecutionUrl, - error: fullRunData.data.resultData.error, - lastNodeExecuted: fullRunData.data.resultData.lastNodeExecuted!, - mode, - retryOf, - }, - workflow: { - id: workflowId, - name: workflowData.name, - }, - }; - } else { - // The error did happen in a trigger - workflowErrorData = { - trigger: { - error: fullRunData.data.resultData.error, - mode, - }, - workflow: { - id: workflowId, - name: workflowData.name, - }, - }; - } - - const { errorTriggerType } = Container.get(GlobalConfig).nodes; - // Run the error workflow - // To avoid an infinite loop do not run the error workflow again if the error-workflow itself failed and it is its own error-workflow. - const { errorWorkflow } = workflowData.settings ?? {}; - if (errorWorkflow && !(mode === 'error' && workflowId && errorWorkflow === workflowId)) { - logger.debug('Start external error workflow', { - executionId, - errorWorkflowId: errorWorkflow, - workflowId, - }); - // If a specific error workflow is set run only that one - - // First, do permission checks. - if (!workflowId) { - // Manual executions do not trigger error workflows - // So this if should never happen. It was added to - // make sure there are no possible security gaps - return; - } - - Container.get(OwnershipService) - .getWorkflowProjectCached(workflowId) - .then((project) => { - void Container.get(WorkflowExecutionService).executeErrorWorkflow( - errorWorkflow, - workflowErrorData, - project, - ); - }) - .catch((error: Error) => { - Container.get(ErrorReporter).error(error); - logger.error( - `Could not execute ErrorWorkflow for execution ID ${this.executionId} because of error querying the workflow owner`, - { - executionId, - errorWorkflowId: errorWorkflow, - workflowId, - error, - workflowErrorData, - }, - ); - }); - } else if ( - mode !== 'error' && - workflowId !== undefined && - workflowData.nodes.some((node) => node.type === errorTriggerType) - ) { - logger.debug('Start internal error workflow', { executionId, workflowId }); - void Container.get(OwnershipService) - .getWorkflowProjectCached(workflowId) - .then((project) => { - void Container.get(WorkflowExecutionService).executeErrorWorkflow( - workflowId, - workflowErrorData, - project, - ); - }); - } - } -} - -/** - * Returns hook functions to push data to Editor-UI - * - */ -function hookFunctionsPush(): IWorkflowExecuteHooks { - const logger = Container.get(Logger); - const pushInstance = Container.get(Push); - return { - nodeExecuteBefore: [ - async function (this: WorkflowHooks, nodeName: string): Promise { - const { pushRef, executionId } = this; - // Push data to session which started workflow before each - // node which starts rendering - if (pushRef === undefined) { - return; - } - - logger.debug(`Executing hook on node "${nodeName}" (hookFunctionsPush)`, { - executionId, - pushRef, - workflowId: this.workflowData.id, - }); - - pushInstance.send({ type: 'nodeExecuteBefore', data: { executionId, nodeName } }, pushRef); - }, - ], - nodeExecuteAfter: [ - async function (this: WorkflowHooks, nodeName: string, data: ITaskData): Promise { - const { pushRef, executionId } = this; - // Push data to session which started workflow after each rendered node - if (pushRef === undefined) { - return; - } - - logger.debug(`Executing hook on node "${nodeName}" (hookFunctionsPush)`, { - executionId, - pushRef, - workflowId: this.workflowData.id, - }); - - pushInstance.send( - { type: 'nodeExecuteAfter', data: { executionId, nodeName, data } }, - pushRef, - ); - }, - ], - workflowExecuteBefore: [ - async function (this: WorkflowHooks, _workflow, data): Promise { - const { pushRef, executionId } = this; - const { id: workflowId, name: workflowName } = this.workflowData; - logger.debug('Executing hook (hookFunctionsPush)', { - executionId, - pushRef, - workflowId, - }); - // Push data to session which started the workflow - if (pushRef === undefined) { - return; - } - pushInstance.send( - { - type: 'executionStarted', - data: { - executionId, - mode: this.mode, - startedAt: new Date(), - retryOf: this.retryOf, - workflowId, - workflowName, - flattedRunData: data?.resultData.runData - ? stringify(data.resultData.runData) - : stringify({}), - }, - }, - pushRef, - ); - }, - ], - workflowExecuteAfter: [ - async function (this: WorkflowHooks, fullRunData: IRun): Promise { - const { pushRef, executionId } = this; - if (pushRef === undefined) return; - - const { id: workflowId } = this.workflowData; - logger.debug('Executing hook (hookFunctionsPush)', { - executionId, - pushRef, - workflowId, - }); - - const { status } = fullRunData; - if (status === 'waiting') { - pushInstance.send({ type: 'executionWaiting', data: { executionId } }, pushRef); - } else { - const rawData = stringify(fullRunData.data); - pushInstance.send( - { type: 'executionFinished', data: { executionId, workflowId, status, rawData } }, - pushRef, - ); - } - }, - ], - }; -} - -export function hookFunctionsPreExecute(): IWorkflowExecuteHooks { - const externalHooks = Container.get(ExternalHooks); - return { - workflowExecuteBefore: [ - async function (this: WorkflowHooks, workflow: Workflow): Promise { - await externalHooks.run('workflow.preExecute', [workflow, this.mode]); - }, - ], - nodeExecuteAfter: [ - async function ( - this: WorkflowHooks, - nodeName: string, - data: ITaskData, - executionData: IRunExecutionData, - ): Promise { - await saveExecutionProgress( - this.workflowData, - this.executionId, - nodeName, - data, - executionData, - this.pushRef, - ); - }, - ], - }; -} - -/** - * Returns hook functions to save workflow execution and call error workflow - * - */ -function hookFunctionsSave(): IWorkflowExecuteHooks { - const logger = Container.get(Logger); - const workflowStatisticsService = Container.get(WorkflowStatisticsService); - const eventService = Container.get(EventService); - return { - nodeExecuteBefore: [ - async function (this: WorkflowHooks, nodeName: string): Promise { - const { executionId, workflowData: workflow } = this; - - eventService.emit('node-pre-execute', { executionId, workflow, nodeName }); - }, - ], - nodeExecuteAfter: [ - async function (this: WorkflowHooks, nodeName: string): Promise { - const { executionId, workflowData: workflow } = this; - - eventService.emit('node-post-execute', { executionId, workflow, nodeName }); - }, - ], - workflowExecuteBefore: [], - workflowExecuteAfter: [ - async function ( - this: WorkflowHooks, - fullRunData: IRun, - newStaticData: IDataObject, - ): Promise { - logger.debug('Executing hook (hookFunctionsSave)', { - executionId: this.executionId, - workflowId: this.workflowData.id, - }); - - await restoreBinaryDataId(fullRunData, this.executionId, this.mode); - - const isManualMode = this.mode === 'manual'; - - try { - if (!isManualMode && isWorkflowIdValid(this.workflowData.id) && newStaticData) { - // Workflow is saved so update in database - try { - await Container.get(WorkflowStaticDataService).saveStaticDataById( - this.workflowData.id, - newStaticData, - ); - } catch (e) { - Container.get(ErrorReporter).error(e); - logger.error( - `There was a problem saving the workflow with id "${this.workflowData.id}" to save changed staticData: "${e.message}" (hookFunctionsSave)`, - { executionId: this.executionId, workflowId: this.workflowData.id }, - ); - } - } - - const executionStatus = determineFinalExecutionStatus(fullRunData); - fullRunData.status = executionStatus; - - const saveSettings = toSaveSettings(this.workflowData.settings); - - if (isManualMode && !saveSettings.manual && !fullRunData.waitTill) { - /** - * When manual executions are not being saved, we only soft-delete - * the execution so that the user can access its binary data - * while building their workflow. - * - * The manual execution and its binary data will be hard-deleted - * on the next pruning cycle after the grace period set by - * `EXECUTIONS_DATA_HARD_DELETE_BUFFER`. - */ - await Container.get(ExecutionRepository).softDelete(this.executionId); - - return; - } - - const shouldNotSave = - (executionStatus === 'success' && !saveSettings.success) || - (executionStatus !== 'success' && !saveSettings.error); - - if (shouldNotSave && !fullRunData.waitTill && !isManualMode) { - executeErrorWorkflow( - this.workflowData, - fullRunData, - this.mode, - this.executionId, - this.retryOf, - ); - - await Container.get(ExecutionRepository).hardDelete({ - workflowId: this.workflowData.id, - executionId: this.executionId, - }); - - return; - } - - // Although it is treated as IWorkflowBase here, it's being instantiated elsewhere with properties that may be sensitive - // As a result, we should create an IWorkflowBase object with only the data we want to save in it. - const fullExecutionData = prepareExecutionDataForDbUpdate({ - runData: fullRunData, - workflowData: this.workflowData, - workflowStatusFinal: executionStatus, - retryOf: this.retryOf, - }); - - // When going into the waiting state, store the pushRef in the execution-data - if (fullRunData.waitTill && isManualMode) { - fullExecutionData.data.pushRef = this.pushRef; - } - - await updateExistingExecution({ - executionId: this.executionId, - workflowId: this.workflowData.id, - executionData: fullExecutionData, - }); - - if (!isManualMode) { - executeErrorWorkflow( - this.workflowData, - fullRunData, - this.mode, - this.executionId, - this.retryOf, - ); - } - } catch (error) { - Container.get(ErrorReporter).error(error); - logger.error(`Failed saving execution data to DB on execution ID ${this.executionId}`, { - executionId: this.executionId, - workflowId: this.workflowData.id, - error, - }); - if (!isManualMode) { - executeErrorWorkflow( - this.workflowData, - fullRunData, - this.mode, - this.executionId, - this.retryOf, - ); - } - } finally { - workflowStatisticsService.emit('workflowExecutionCompleted', { - workflowData: this.workflowData, - fullRunData, - }); - } - }, - ], - nodeFetchedData: [ - async (workflowId: string, node: INode) => { - workflowStatisticsService.emit('nodeFetchedData', { workflowId, node }); - }, - ], - }; -} - -/** - * Returns hook functions to save workflow execution and call error workflow - * for running with queues. Manual executions should never run on queues as - * they are always executed in the main process. - * - */ -function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { - const logger = Container.get(Logger); - const workflowStatisticsService = Container.get(WorkflowStatisticsService); - const eventService = Container.get(EventService); - return { - nodeExecuteBefore: [ - async function (this: WorkflowHooks, nodeName: string): Promise { - const { executionId, workflowData: workflow } = this; - - eventService.emit('node-pre-execute', { executionId, workflow, nodeName }); - }, - ], - nodeExecuteAfter: [ - async function (this: WorkflowHooks, nodeName: string): Promise { - const { executionId, workflowData: workflow } = this; - - eventService.emit('node-post-execute', { executionId, workflow, nodeName }); - }, - ], - workflowExecuteBefore: [ - async function (): Promise { - const { executionId, workflowData } = this; - - eventService.emit('workflow-pre-execute', { executionId, data: workflowData }); - }, - ], - workflowExecuteAfter: [ - async function ( - this: WorkflowHooks, - fullRunData: IRun, - newStaticData: IDataObject, - ): Promise { - logger.debug('Executing hook (hookFunctionsSaveWorker)', { - executionId: this.executionId, - workflowId: this.workflowData.id, - }); - try { - if (isWorkflowIdValid(this.workflowData.id) && newStaticData) { - // Workflow is saved so update in database - try { - await Container.get(WorkflowStaticDataService).saveStaticDataById( - this.workflowData.id, - newStaticData, - ); - } catch (e) { - Container.get(ErrorReporter).error(e); - logger.error( - `There was a problem saving the workflow with id "${this.workflowData.id}" to save changed staticData: "${e.message}" (workflowExecuteAfter)`, - { pushRef: this.pushRef, workflowId: this.workflowData.id }, - ); - } - } - - const workflowStatusFinal = determineFinalExecutionStatus(fullRunData); - fullRunData.status = workflowStatusFinal; - - if (workflowStatusFinal !== 'success' && workflowStatusFinal !== 'waiting') { - executeErrorWorkflow( - this.workflowData, - fullRunData, - this.mode, - this.executionId, - this.retryOf, - ); - } - - // Although it is treated as IWorkflowBase here, it's being instantiated elsewhere with properties that may be sensitive - // As a result, we should create an IWorkflowBase object with only the data we want to save in it. - const fullExecutionData = prepareExecutionDataForDbUpdate({ - runData: fullRunData, - workflowData: this.workflowData, - workflowStatusFinal, - retryOf: this.retryOf, - }); - - await updateExistingExecution({ - executionId: this.executionId, - workflowId: this.workflowData.id, - executionData: fullExecutionData, - }); - } catch (error) { - executeErrorWorkflow( - this.workflowData, - fullRunData, - this.mode, - this.executionId, - this.retryOf, - ); - } finally { - workflowStatisticsService.emit('workflowExecutionCompleted', { - workflowData: this.workflowData, - fullRunData, - }); - } - }, - async function (this: WorkflowHooks, runData: IRun): Promise { - const { executionId, workflowData: workflow } = this; - - eventService.emit('workflow-post-execute', { - workflow, - executionId, - runData, - }); - }, - async function (this: WorkflowHooks, fullRunData: IRun) { - const externalHooks = Container.get(ExternalHooks); - if (externalHooks.exists('workflow.postExecute')) { - try { - await externalHooks.run('workflow.postExecute', [ - fullRunData, - this.workflowData, - this.executionId, - ]); - } catch (error) { - Container.get(ErrorReporter).error(error); - Container.get(Logger).error( - 'There was a problem running hook "workflow.postExecute"', - error, - ); - } - } - }, - ], - nodeFetchedData: [ - async (workflowId: string, node: INode) => { - workflowStatisticsService.emit('nodeFetchedData', { workflowId, node }); - }, - ], - }; -} - export async function getRunData( workflowData: IWorkflowBase, inputData?: INodeExecutionData[], @@ -1061,137 +441,3 @@ export async function getBase( eventService.emit(eventName, payload), }; } - -/** - * Returns WorkflowHooks instance for running integrated workflows - * (Workflows which get started inside of another workflow) - */ -function getWorkflowHooksIntegrated( - mode: WorkflowExecuteMode, - executionId: string, - workflowData: IWorkflowBase, -): WorkflowHooks { - const hookFunctions = hookFunctionsSave(); - const preExecuteFunctions = hookFunctionsPreExecute(); - for (const key of Object.keys(preExecuteFunctions)) { - const hooks = hookFunctions[key] ?? []; - hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]); - } - return new WorkflowHooks(hookFunctions, mode, executionId, workflowData); -} - -/** - * Returns WorkflowHooks instance for worker in scaling mode. - */ -export function getWorkflowHooksWorkerExecuter( - mode: WorkflowExecuteMode, - executionId: string, - workflowData: IWorkflowBase, - optionalParameters?: IWorkflowHooksOptionalParameters, -): WorkflowHooks { - optionalParameters = optionalParameters || {}; - const hookFunctions = hookFunctionsSaveWorker(); - const preExecuteFunctions = hookFunctionsPreExecute(); - for (const key of Object.keys(preExecuteFunctions)) { - const hooks = hookFunctions[key] ?? []; - hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]); - } - - if (mode === 'manual' && Container.get(InstanceSettings).isWorker) { - const pushHooks = hookFunctionsPush(); - for (const key of Object.keys(pushHooks)) { - if (hookFunctions[key] === undefined) { - hookFunctions[key] = []; - } - // eslint-disable-next-line prefer-spread - hookFunctions[key].push.apply(hookFunctions[key], pushHooks[key]); - } - } - - return new WorkflowHooks(hookFunctions, mode, executionId, workflowData, optionalParameters); -} - -/** - * Returns WorkflowHooks instance for main process if workflow runs via worker - */ -export function getWorkflowHooksWorkerMain( - mode: WorkflowExecuteMode, - executionId: string, - workflowData: IWorkflowBase, - optionalParameters?: IWorkflowHooksOptionalParameters, -): WorkflowHooks { - optionalParameters = optionalParameters || {}; - const hookFunctions = hookFunctionsPreExecute(); - - // TODO: why are workers pushing to frontend? - // TODO: simplifying this for now to just leave the bare minimum hooks - - // const hookFunctions = hookFunctionsPush(); - // const preExecuteFunctions = hookFunctionsPreExecute(); - // for (const key of Object.keys(preExecuteFunctions)) { - // if (hookFunctions[key] === undefined) { - // hookFunctions[key] = []; - // } - // hookFunctions[key]!.push.apply(hookFunctions[key], preExecuteFunctions[key]); - // } - - // When running with worker mode, main process executes - // Only workflowExecuteBefore + workflowExecuteAfter - // So to avoid confusion, we are removing other hooks. - hookFunctions.nodeExecuteBefore = []; - hookFunctions.nodeExecuteAfter = []; - hookFunctions.workflowExecuteAfter = [ - async function (this: WorkflowHooks, fullRunData: IRun): Promise { - // Don't delete executions before they are finished - if (!fullRunData.finished) return; - - const executionStatus = determineFinalExecutionStatus(fullRunData); - fullRunData.status = executionStatus; - - const saveSettings = toSaveSettings(this.workflowData.settings); - - const shouldNotSave = - (executionStatus === 'success' && !saveSettings.success) || - (executionStatus !== 'success' && !saveSettings.error); - - if (shouldNotSave) { - await Container.get(ExecutionRepository).hardDelete({ - workflowId: this.workflowData.id, - executionId: this.executionId, - }); - } - }, - ]; - - return new WorkflowHooks(hookFunctions, mode, executionId, workflowData, optionalParameters); -} - -/** - * Returns WorkflowHooks instance for running the main workflow - * - */ -export function getWorkflowHooksMain( - data: IWorkflowExecutionDataProcess, - executionId: string, -): WorkflowHooks { - const hookFunctions = hookFunctionsSave(); - const pushFunctions = hookFunctionsPush(); - for (const key of Object.keys(pushFunctions)) { - const hooks = hookFunctions[key] ?? []; - hooks.push.apply(hookFunctions[key], pushFunctions[key]); - } - - const preExecuteFunctions = hookFunctionsPreExecute(); - for (const key of Object.keys(preExecuteFunctions)) { - const hooks = hookFunctions[key] ?? []; - hooks.push.apply(hookFunctions[key], preExecuteFunctions[key]); - } - - if (!hookFunctions.nodeExecuteBefore) hookFunctions.nodeExecuteBefore = []; - if (!hookFunctions.nodeExecuteAfter) hookFunctions.nodeExecuteAfter = []; - - return new WorkflowHooks(hookFunctions, data.executionMode, executionId, data.workflowData, { - pushRef: data.pushRef, - retryOf: data.retryOf as string, - }); -} diff --git a/packages/cli/src/workflow-runner.ts b/packages/cli/src/workflow-runner.ts index 598e6a8b58..148df7edcd 100644 --- a/packages/cli/src/workflow-runner.ts +++ b/packages/cli/src/workflow-runner.ts @@ -20,7 +20,15 @@ import PCancelable from 'p-cancelable'; import { ActiveExecutions } from '@/active-executions'; import config from '@/config'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; +import { ExecutionNotFoundError } from '@/errors/execution-not-found-error'; +import { EventService } from '@/events/event.service'; +import { + getWorkflowHooksMain, + getWorkflowHooksWorkerExecuter, + getWorkflowHooksWorkerMain, +} from '@/execution-lifecycle/execution-lifecycle-hooks'; import { ExternalHooks } from '@/external-hooks'; +import { ManualExecutionService } from '@/manual-execution.service'; import { NodeTypes } from '@/node-types'; import type { ScalingService } from '@/scaling/scaling.service'; import type { Job, JobData } from '@/scaling/scaling.types'; @@ -29,10 +37,6 @@ import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-da import { generateFailedExecutionFromError } from '@/workflow-helpers'; import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; -import { ExecutionNotFoundError } from './errors/execution-not-found-error'; -import { EventService } from './events/event.service'; -import { ManualExecutionService } from './manual-execution.service'; - @Service() export class WorkflowRunner { private scalingService: ScalingService; @@ -138,7 +142,7 @@ export class WorkflowRunner { } catch (error) { // Create a failed execution with the data for the node, save it and abort execution const runData = generateFailedExecutionFromError(data.executionMode, error, error.node); - const workflowHooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain(data, executionId); + const workflowHooks = getWorkflowHooksMain(data, executionId); await workflowHooks.executeHookFunctions('workflowExecuteBefore', [ undefined, data.executionData, @@ -267,7 +271,7 @@ export class WorkflowRunner { await this.executionRepository.setRunning(executionId); // write try { - additionalData.hooks = WorkflowExecuteAdditionalData.getWorkflowHooksMain(data, executionId); + additionalData.hooks = getWorkflowHooksMain(data, executionId); additionalData.hooks.hookFunctions.sendResponse = [ async (response: IExecuteResponsePromiseData): Promise => { @@ -368,12 +372,9 @@ export class WorkflowRunner { try { job = await this.scalingService.addJob(jobData, { priority: realtime ? 50 : 100 }); - hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerMain( - data.executionMode, - executionId, - data.workflowData, - { retryOf: data.retryOf ? data.retryOf.toString() : undefined }, - ); + hooks = getWorkflowHooksWorkerMain(data.executionMode, executionId, data.workflowData, { + retryOf: data.retryOf ? data.retryOf.toString() : undefined, + }); // Normally also workflow should be supplied here but as it only used for sending // data to editor-UI is not needed. @@ -381,7 +382,7 @@ export class WorkflowRunner { } catch (error) { // We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the // "workflowExecuteAfter" which we require. - const hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter( + const hooks = getWorkflowHooksWorkerExecuter( data.executionMode, executionId, data.workflowData, @@ -399,7 +400,7 @@ export class WorkflowRunner { // We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the // "workflowExecuteAfter" which we require. - const hooksWorker = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter( + const hooksWorker = getWorkflowHooksWorkerExecuter( data.executionMode, executionId, data.workflowData, @@ -417,7 +418,7 @@ export class WorkflowRunner { } catch (error) { // We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the // "workflowExecuteAfter" which we require. - const hooks = WorkflowExecuteAdditionalData.getWorkflowHooksWorkerExecuter( + const hooks = getWorkflowHooksWorkerExecuter( data.executionMode, executionId, data.workflowData, diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 6460348b23..fdb53c1832 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -272,6 +272,13 @@ export class WorkflowService { return updatedWorkflow; } + /** + * Deletes a workflow and returns it. + * + * If the workflow is active this will deactivate the workflow. + * If the user does not have the permissions to delete the workflow this does + * nothing and returns void. + */ async delete(user: User, workflowId: string): Promise { await this.externalHooks.run('workflow.delete', [workflowId]); diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index ee868b072f..6bad1a02d8 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -2,6 +2,11 @@ + + + + +

{{formTitle}}

-

{{formDescription}}

+

{{{formDescription}}}

@@ -366,6 +371,12 @@
{{/if}} + {{#if isHtml}} +
+ {{{html}}} +
+ {{/if}} + {{#if isTextarea}}
diff --git a/packages/cli/templates/oauth-callback.handlebars b/packages/cli/templates/oauth-callback.handlebars index c0d8a0cfab..74d57db303 100644 --- a/packages/cli/templates/oauth-callback.handlebars +++ b/packages/cli/templates/oauth-callback.handlebars @@ -1,10 +1,85 @@ - + + + + +
+
+
+ +
+
+ + + + +

Connection successful

+
+
+

This window will close automatically in 5 seconds.

+
+
+
+ + diff --git a/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts b/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts index f20f9df550..6411966dbf 100644 --- a/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts +++ b/packages/cli/test/integration/controllers/oauth/oauth2.api.test.ts @@ -116,7 +116,7 @@ describe('OAuth2 API', () => { .query({ code: 'auth_code', state }) .expect(200); - expect(renderSpy).toHaveBeenCalledWith('oauth-callback'); + expect(renderSpy).toHaveBeenCalledWith('oauth-callback', { imagePath: 'n8n-logo.png' }); const updatedCredential = await Container.get(CredentialsHelper).getCredentials( credential, diff --git a/packages/cli/test/integration/database/repositories/workflow.repository.test.ts b/packages/cli/test/integration/database/repositories/workflow.repository.test.ts index 63b87df7e8..c92dcdde0a 100644 --- a/packages/cli/test/integration/database/repositories/workflow.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/workflow.repository.test.ts @@ -73,7 +73,7 @@ describe('WorkflowRepository', () => { }); describe('getActiveIds', () => { - it('should return active workflow IDs', async () => { + it('should return all active workflow IDs when invoked without maxResults', async () => { // // ARRANGE // @@ -92,6 +92,28 @@ describe('WorkflowRepository', () => { // ASSERT // expect(activeIds).toEqual([workflows[0].id]); + expect(activeIds).toHaveLength(1); + }); + + it('should return a capped number of active workflow IDs when invoked with maxResults', async () => { + // + // ARRANGE + // + await Promise.all([ + createWorkflow({ active: true }), + createWorkflow({ active: false }), + createWorkflow({ active: true }), + ]); + + // + // ACT + // + const activeIds = await Container.get(WorkflowRepository).getActiveIds({ maxResults: 1 }); + + // + // ASSERT + // + expect(activeIds).toHaveLength(1); }); }); }); diff --git a/packages/cli/test/integration/environments/source-control-import.service.test.ts b/packages/cli/test/integration/environments/source-control-import.service.test.ts index 8b774f8fa9..5bfd5d0e79 100644 --- a/packages/cli/test/integration/environments/source-control-import.service.test.ts +++ b/packages/cli/test/integration/environments/source-control-import.service.test.ts @@ -50,6 +50,9 @@ describe('SourceControlImportService', () => { mock(), mock(), mock(), + mock(), + mock(), + mock(), mock({ n8nFolder: '/some-path' }), ); }); diff --git a/packages/cli/test/integration/evaluation/test-runs.api.test.ts b/packages/cli/test/integration/evaluation/test-runs.api.test.ts index 3fcc321cc9..9690d1b50e 100644 --- a/packages/cli/test/integration/evaluation/test-runs.api.test.ts +++ b/packages/cli/test/integration/evaluation/test-runs.api.test.ts @@ -1,4 +1,5 @@ import { Container } from '@n8n/di'; +import { mockInstance } from 'n8n-core/test/utils'; import type { TestDefinition } from '@/databases/entities/test-definition.ee'; import type { User } from '@/databases/entities/user'; @@ -6,6 +7,7 @@ import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { ProjectRepository } from '@/databases/repositories/project.repository'; import { TestDefinitionRepository } from '@/databases/repositories/test-definition.repository.ee'; import { TestRunRepository } from '@/databases/repositories/test-run.repository.ee'; +import { TestRunnerService } from '@/evaluation.ee/test-runner/test-runner.service.ee'; import { createUserShell } from '@test-integration/db/users'; import { createWorkflow } from '@test-integration/db/workflows'; import * as testDb from '@test-integration/test-db'; @@ -19,9 +21,11 @@ let testDefinition: TestDefinition; let otherTestDefinition: TestDefinition; let ownerShell: User; +const testRunner = mockInstance(TestRunnerService); + const testServer = utils.setupTestServer({ endpointGroups: ['workflows', 'evaluation'], - enabledFeatures: ['feat:sharing'], + enabledFeatures: ['feat:sharing', 'feat:multipleMainInstances'], }); beforeAll(async () => { @@ -57,13 +61,13 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs', () => { expect(resp.body.data).toEqual([]); }); - test('should retrieve 404 if test definition does not exist', async () => { + test('should return 404 if test definition does not exist', async () => { const resp = await authOwnerAgent.get('/evaluation/test-definitions/123/runs'); expect(resp.statusCode).toBe(404); }); - test('should retrieve 404 if user does not have access to test definition', async () => { + test('should return 404 if user does not have access to test definition', async () => { const resp = await authOwnerAgent.get( `/evaluation/test-definitions/${otherTestDefinition.id}/runs`, ); @@ -151,7 +155,7 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs/:id', () => { ); }); - test('should retrieve 404 if test run does not exist', async () => { + test('should return 404 if test run does not exist', async () => { const resp = await authOwnerAgent.get( `/evaluation/test-definitions/${testDefinition.id}/runs/123`, ); @@ -159,7 +163,7 @@ describe('GET /evaluation/test-definitions/:testDefinitionId/runs/:id', () => { expect(resp.statusCode).toBe(404); }); - test('should retrieve 404 if user does not have access to test definition', async () => { + test('should return 404 if user does not have access to test definition', async () => { const testRunRepository = Container.get(TestRunRepository); const testRun = await testRunRepository.createTestRun(otherTestDefinition.id); @@ -218,7 +222,7 @@ describe('DELETE /evaluation/test-definitions/:testDefinitionId/runs/:id', () => expect(testRunAfterDelete).toBeNull(); }); - test('should retrieve 404 if test run does not exist', async () => { + test('should return 404 if test run does not exist', async () => { const resp = await authOwnerAgent.delete( `/evaluation/test-definitions/${testDefinition.id}/runs/123`, ); @@ -226,7 +230,7 @@ describe('DELETE /evaluation/test-definitions/:testDefinitionId/runs/:id', () => expect(resp.statusCode).toBe(404); }); - test('should retrieve 404 if user does not have access to test definition', async () => { + test('should return 404 if user does not have access to test definition', async () => { const testRunRepository = Container.get(TestRunRepository); const testRun = await testRunRepository.createTestRun(otherTestDefinition.id); @@ -237,3 +241,46 @@ describe('DELETE /evaluation/test-definitions/:testDefinitionId/runs/:id', () => expect(resp.statusCode).toBe(404); }); }); + +describe('POST /evaluation/test-definitions/:testDefinitionId/runs/:id/cancel', () => { + test('should cancel test run', async () => { + const testRunRepository = Container.get(TestRunRepository); + const testRun = await testRunRepository.createTestRun(testDefinition.id); + + jest.spyOn(testRunRepository, 'markAsCancelled'); + + const resp = await authOwnerAgent.post( + `/evaluation/test-definitions/${testDefinition.id}/runs/${testRun.id}/cancel`, + ); + + expect(resp.statusCode).toBe(202); + expect(resp.body).toEqual({ success: true }); + + expect(testRunner.cancelTestRun).toHaveBeenCalledWith(testRun.id); + }); + + test('should return 404 if test run does not exist', async () => { + const resp = await authOwnerAgent.post( + `/evaluation/test-definitions/${testDefinition.id}/runs/123/cancel`, + ); + + expect(resp.statusCode).toBe(404); + }); + + test('should return 404 if test definition does not exist', async () => { + const resp = await authOwnerAgent.post('/evaluation/test-definitions/123/runs/123/cancel'); + + expect(resp.statusCode).toBe(404); + }); + + test('should return 404 if user does not have access to test definition', async () => { + const testRunRepository = Container.get(TestRunRepository); + const testRun = await testRunRepository.createTestRun(otherTestDefinition.id); + + const resp = await authOwnerAgent.post( + `/evaluation/test-definitions/${otherTestDefinition.id}/runs/${testRun.id}/cancel`, + ); + + expect(resp.statusCode).toBe(404); + }); +}); diff --git a/packages/core/.eslintrc.js b/packages/core/.eslintrc.js index 9bfae8a9eb..ceb431ce40 100644 --- a/packages/core/.eslintrc.js +++ b/packages/core/.eslintrc.js @@ -16,6 +16,7 @@ module.exports = { rules: { complexity: 'error', + 'unicorn/filename-case': ['error', { case: 'kebabCase' }], // TODO: Remove this '@typescript-eslint/ban-ts-comment': ['error', { 'ts-ignore': true }], diff --git a/packages/core/bin/generate-metadata b/packages/core/bin/generate-metadata index 18dbca687a..b4001ccd84 100755 --- a/packages/core/bin/generate-metadata +++ b/packages/core/bin/generate-metadata @@ -1,7 +1,7 @@ #!/usr/bin/env node const { LoggerProxy } = require('n8n-workflow'); -const { PackageDirectoryLoader } = require('../dist/DirectoryLoader'); +const { PackageDirectoryLoader } = require('../dist/nodes-loader/package-directory-loader'); const { packageDir, writeJSON } = require('./common'); LoggerProxy.init(console); diff --git a/packages/core/package.json b/packages/core/package.json index 8773d134ea..ec0c5e3c6c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.74.0", + "version": "1.76.0", "description": "Core functionality of n8n", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/core/src/PartialExecutionUtils/__tests__/cleanRunData.test.ts b/packages/core/src/PartialExecutionUtils/__tests__/cleanRunData.test.ts deleted file mode 100644 index 959ab78845..0000000000 --- a/packages/core/src/PartialExecutionUtils/__tests__/cleanRunData.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { IRunData } from 'n8n-workflow'; - -import { createNodeData, toITaskData } from './helpers'; -import { cleanRunData } from '../cleanRunData'; -import { DirectedGraph } from '../DirectedGraph'; - -describe('cleanRunData', () => { - // ┌─────┐ ┌─────┐ ┌─────┐ - // │node1├───►│node2├──►│node3│ - // └─────┘ └─────┘ └─────┘ - test('deletes all run data of all children and the node being passed in', () => { - // ARRANGE - const node1 = createNodeData({ name: 'Node1' }); - const node2 = createNodeData({ name: 'Node2' }); - const node3 = createNodeData({ name: 'Node3' }); - const graph = new DirectedGraph() - .addNodes(node1, node2, node3) - .addConnections({ from: node1, to: node2 }, { from: node2, to: node3 }); - const runData: IRunData = { - [node1.name]: [toITaskData([{ data: { value: 1 } }])], - [node2.name]: [toITaskData([{ data: { value: 2 } }])], - [node3.name]: [toITaskData([{ data: { value: 3 } }])], - }; - - // ACT - const newRunData = cleanRunData(runData, graph, new Set([node1])); - - // ASSERT - expect(newRunData).toEqual({}); - }); - - // ┌─────┐ ┌─────┐ ┌─────┐ - // │node1├───►│node2├──►│node3│ - // └─────┘ └─────┘ └─────┘ - test('retains the run data of parent nodes of the node being passed in', () => { - // ARRANGE - const node1 = createNodeData({ name: 'Node1' }); - const node2 = createNodeData({ name: 'Node2' }); - const node3 = createNodeData({ name: 'Node3' }); - const graph = new DirectedGraph() - .addNodes(node1, node2, node3) - .addConnections({ from: node1, to: node2 }, { from: node2, to: node3 }); - const runData: IRunData = { - [node1.name]: [toITaskData([{ data: { value: 1 } }])], - [node2.name]: [toITaskData([{ data: { value: 2 } }])], - [node3.name]: [toITaskData([{ data: { value: 3 } }])], - }; - - // ACT - const newRunData = cleanRunData(runData, graph, new Set([node2])); - - // ASSERT - expect(newRunData).toEqual({ [node1.name]: runData[node1.name] }); - }); - - // ┌─────┐ ┌─────┐ ┌─────┐ - // ┌─►│node1├───►│node2├──►│node3├─┐ - // │ └─────┘ └─────┘ └─────┘ │ - // │ │ - // └───────────────────────────────┘ - test('terminates when finding a cycle', () => { - // ARRANGE - const node1 = createNodeData({ name: 'Node1' }); - const node2 = createNodeData({ name: 'Node2' }); - const node3 = createNodeData({ name: 'Node3' }); - const graph = new DirectedGraph() - .addNodes(node1, node2, node3) - .addConnections( - { from: node1, to: node2 }, - { from: node2, to: node3 }, - { from: node3, to: node1 }, - ); - - const runData: IRunData = { - [node1.name]: [toITaskData([{ data: { value: 1 } }])], - [node2.name]: [toITaskData([{ data: { value: 2 } }])], - [node3.name]: [toITaskData([{ data: { value: 3 } }])], - }; - - // ACT - const newRunData = cleanRunData(runData, graph, new Set([node2])); - - // ASSERT - // TODO: Find out if this is a desirable result in milestone 2 - expect(newRunData).toEqual({}); - }); - - // ┌─────┐ ┌─────┐ - // │node1├───►│node2│ - // └─────┘ └─────┘ - test('removes run data of nodes that are not in the subgraph', () => { - // ARRANGE - const node1 = createNodeData({ name: 'Node1' }); - const node2 = createNodeData({ name: 'Node2' }); - const graph = new DirectedGraph() - .addNodes(node1, node2) - .addConnections({ from: node1, to: node2 }); - // not part of the graph - const node3 = createNodeData({ name: 'Node3' }); - const runData: IRunData = { - [node1.name]: [toITaskData([{ data: { value: 1 } }])], - [node2.name]: [toITaskData([{ data: { value: 2 } }])], - [node3.name]: [toITaskData([{ data: { value: 3 } }])], - }; - - // ACT - const newRunData = cleanRunData(runData, graph, new Set([node2])); - - // ASSERT - expect(newRunData).toEqual({ - [node1.name]: [toITaskData([{ data: { value: 1 } }])], - }); - }); -}); diff --git a/packages/core/src/PartialExecutionUtils/index.ts b/packages/core/src/PartialExecutionUtils/index.ts deleted file mode 100644 index f85202cfa1..0000000000 --- a/packages/core/src/PartialExecutionUtils/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { DirectedGraph } from './DirectedGraph'; -export { findTriggerForPartialExecution } from './findTriggerForPartialExecution'; -export { findStartNodes } from './findStartNodes'; -export { findSubgraph } from './findSubgraph'; -export { recreateNodeExecutionStack } from './recreateNodeExecutionStack'; -export { cleanRunData } from './cleanRunData'; -export { handleCycles } from './handleCycles'; -export { filterDisabledNodes } from './filterDisabledNodes'; diff --git a/packages/core/test/Credentials.test.ts b/packages/core/src/__tests__/credentials.test.ts similarity index 91% rename from packages/core/test/Credentials.test.ts rename to packages/core/src/__tests__/credentials.test.ts index bf6ba29ca5..366477def7 100644 --- a/packages/core/test/Credentials.test.ts +++ b/packages/core/src/__tests__/credentials.test.ts @@ -2,9 +2,10 @@ import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import type { CredentialInformation } from 'n8n-workflow'; -import { Cipher } from '@/Cipher'; -import { Credentials } from '@/Credentials'; -import type { InstanceSettings } from '@/InstanceSettings'; +import { Cipher } from '@/encryption/cipher'; +import type { InstanceSettings } from '@/instance-settings'; + +import { Credentials } from '../credentials'; describe('Credentials', () => { const cipher = new Cipher(mock({ encryptionKey: 'password' })); diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/src/__tests__/node-execute-functions.test.ts similarity index 99% rename from packages/core/test/NodeExecuteFunctions.test.ts rename to packages/core/src/__tests__/node-execute-functions.test.ts index 703e3f9bef..a1c41355e6 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/src/__tests__/node-execute-functions.test.ts @@ -21,8 +21,8 @@ import { join } from 'path'; import { Readable } from 'stream'; import type { SecureContextOptions } from 'tls'; -import { BinaryDataService } from '@/BinaryData/BinaryData.service'; -import { InstanceSettings } from '@/InstanceSettings'; +import { BinaryDataService } from '@/binary-data/binary-data.service'; +import { InstanceSettings } from '@/instance-settings'; import { binaryToString, copyInputItems, @@ -36,7 +36,7 @@ import { proxyRequestToAxios, removeEmptyBody, setBinaryDataBuffer, -} from '@/NodeExecuteFunctions'; +} from '@/node-execute-functions'; const temporaryDir = mkdtempSync(join(tmpdir(), 'n8n')); diff --git a/packages/core/test/FileSystem.manager.test.ts b/packages/core/src/binary-data/__tests__/file-system.manager.test.ts similarity index 96% rename from packages/core/test/FileSystem.manager.test.ts rename to packages/core/src/binary-data/__tests__/file-system.manager.test.ts index edb6bd5e77..fae24801f5 100644 --- a/packages/core/test/FileSystem.manager.test.ts +++ b/packages/core/src/binary-data/__tests__/file-system.manager.test.ts @@ -3,10 +3,9 @@ import fsp from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { FileSystemManager } from '@/BinaryData/FileSystem.manager'; -import { isStream } from '@/ObjectStore/utils'; - -import { toFileId, toStream } from './utils'; +import { FileSystemManager } from '@/binary-data/file-system.manager'; +import { isStream } from '@/binary-data/object-store/utils'; +import { toFileId, toStream } from '@test/utils'; jest.mock('fs'); jest.mock('fs/promises'); diff --git a/packages/core/test/ObjectStore.manager.test.ts b/packages/core/src/binary-data/__tests__/object-store.manager.test.ts similarity index 91% rename from packages/core/test/ObjectStore.manager.test.ts rename to packages/core/src/binary-data/__tests__/object-store.manager.test.ts index f01e170213..9ca99e8d7b 100644 --- a/packages/core/test/ObjectStore.manager.test.ts +++ b/packages/core/src/binary-data/__tests__/object-store.manager.test.ts @@ -1,12 +1,11 @@ import { mock } from 'jest-mock-extended'; import fs from 'node:fs/promises'; -import { ObjectStoreManager } from '@/BinaryData/ObjectStore.manager'; -import { ObjectStoreService } from '@/ObjectStore/ObjectStore.service.ee'; -import type { MetadataResponseHeaders } from '@/ObjectStore/types'; -import { isStream } from '@/ObjectStore/utils'; - -import { mockInstance, toFileId, toStream } from './utils'; +import { ObjectStoreService } from '@/binary-data/object-store/object-store.service.ee'; +import type { MetadataResponseHeaders } from '@/binary-data/object-store/types'; +import { isStream } from '@/binary-data/object-store/utils'; +import { ObjectStoreManager } from '@/binary-data/object-store.manager'; +import { mockInstance, toFileId, toStream } from '@test/utils'; jest.mock('fs/promises'); diff --git a/packages/core/test/BinaryData/utils.test.ts b/packages/core/src/binary-data/__tests__/utils.test.ts similarity index 95% rename from packages/core/test/BinaryData/utils.test.ts rename to packages/core/src/binary-data/__tests__/utils.test.ts index 50a7f165df..329345262f 100644 --- a/packages/core/test/BinaryData/utils.test.ts +++ b/packages/core/src/binary-data/__tests__/utils.test.ts @@ -1,7 +1,7 @@ import { Readable } from 'node:stream'; import { createGunzip } from 'node:zlib'; -import { binaryToBuffer } from '@/BinaryData/utils'; +import { binaryToBuffer } from '@/binary-data/utils'; describe('BinaryData/utils', () => { describe('binaryToBuffer', () => { diff --git a/packages/core/src/BinaryData/BinaryData.service.ts b/packages/core/src/binary-data/binary-data.service.ts similarity index 96% rename from packages/core/src/BinaryData/BinaryData.service.ts rename to packages/core/src/binary-data/binary-data.service.ts index 5876ae075e..742ceb500f 100644 --- a/packages/core/src/BinaryData/BinaryData.service.ts +++ b/packages/core/src/binary-data/binary-data.service.ts @@ -22,7 +22,7 @@ export class BinaryDataService { this.mode = config.mode === 'filesystem' ? 'filesystem-v2' : config.mode; if (config.availableModes.includes('filesystem')) { - const { FileSystemManager } = await import('./FileSystem.manager'); + const { FileSystemManager } = await import('./file-system.manager'); this.managers.filesystem = new FileSystemManager(config.localStoragePath); this.managers['filesystem-v2'] = this.managers.filesystem; @@ -31,8 +31,8 @@ export class BinaryDataService { } if (config.availableModes.includes('s3')) { - const { ObjectStoreManager } = await import('./ObjectStore.manager'); - const { ObjectStoreService } = await import('../ObjectStore/ObjectStore.service.ee'); + const { ObjectStoreManager } = await import('./object-store.manager'); + const { ObjectStoreService } = await import('./object-store/object-store.service.ee'); this.managers.s3 = new ObjectStoreManager(Container.get(ObjectStoreService)); diff --git a/packages/core/src/BinaryData/FileSystem.manager.ts b/packages/core/src/binary-data/file-system.manager.ts similarity index 100% rename from packages/core/src/BinaryData/FileSystem.manager.ts rename to packages/core/src/binary-data/file-system.manager.ts diff --git a/packages/core/src/binary-data/index.ts b/packages/core/src/binary-data/index.ts new file mode 100644 index 0000000000..ef82dee30f --- /dev/null +++ b/packages/core/src/binary-data/index.ts @@ -0,0 +1,4 @@ +export * from './binary-data.service'; +export * from './types'; +export { ObjectStoreService } from './object-store/object-store.service.ee'; +export { isStoredMode as isValidNonDefaultMode } from './utils'; diff --git a/packages/core/src/BinaryData/ObjectStore.manager.ts b/packages/core/src/binary-data/object-store.manager.ts similarity index 97% rename from packages/core/src/BinaryData/ObjectStore.manager.ts rename to packages/core/src/binary-data/object-store.manager.ts index 5a2ab8ef55..cc0fa564ce 100644 --- a/packages/core/src/BinaryData/ObjectStore.manager.ts +++ b/packages/core/src/binary-data/object-store.manager.ts @@ -3,9 +3,9 @@ import fs from 'node:fs/promises'; import type { Readable } from 'node:stream'; import { v4 as uuid } from 'uuid'; +import { ObjectStoreService } from './object-store/object-store.service.ee'; import type { BinaryData } from './types'; import { binaryToBuffer } from './utils'; -import { ObjectStoreService } from '../ObjectStore/ObjectStore.service.ee'; @Service() export class ObjectStoreManager implements BinaryData.Manager { diff --git a/packages/core/test/ObjectStore.service.test.ts b/packages/core/src/binary-data/object-store/__tests__/object-store.service.test.ts similarity index 98% rename from packages/core/test/ObjectStore.service.test.ts rename to packages/core/src/binary-data/object-store/__tests__/object-store.service.test.ts index 9899ad17fc..f5d2924eb5 100644 --- a/packages/core/test/ObjectStore.service.test.ts +++ b/packages/core/src/binary-data/object-store/__tests__/object-store.service.test.ts @@ -2,8 +2,8 @@ import axios from 'axios'; import { mock } from 'jest-mock-extended'; import { Readable } from 'stream'; -import { ObjectStoreService } from '@/ObjectStore/ObjectStore.service.ee'; -import { writeBlockedMessage } from '@/ObjectStore/utils'; +import { ObjectStoreService } from '@/binary-data/object-store/object-store.service.ee'; +import { writeBlockedMessage } from '@/binary-data/object-store/utils'; jest.mock('axios'); diff --git a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts b/packages/core/src/binary-data/object-store/object-store.service.ee.ts similarity index 99% rename from packages/core/src/ObjectStore/ObjectStore.service.ee.ts rename to packages/core/src/binary-data/object-store/object-store.service.ee.ts index e1b98c6f50..508477d50e 100644 --- a/packages/core/src/ObjectStore/ObjectStore.service.ee.ts +++ b/packages/core/src/binary-data/object-store/object-store.service.ee.ts @@ -18,7 +18,7 @@ import type { RequestOptions, } from './types'; import { isStream, parseXml, writeBlockedMessage } from './utils'; -import type { BinaryData } from '../BinaryData/types'; +import type { BinaryData } from '../types'; @Service() export class ObjectStoreService { diff --git a/packages/core/src/ObjectStore/types.ts b/packages/core/src/binary-data/object-store/types.ts similarity index 94% rename from packages/core/src/ObjectStore/types.ts rename to packages/core/src/binary-data/object-store/types.ts index d0b7ab0713..49726f5c43 100644 --- a/packages/core/src/ObjectStore/types.ts +++ b/packages/core/src/binary-data/object-store/types.ts @@ -1,6 +1,6 @@ import type { AxiosResponseHeaders, ResponseType } from 'axios'; -import type { BinaryData } from '../BinaryData/types'; +import type { BinaryData } from '../types'; export type RawListPage = { listBucketResult: { diff --git a/packages/core/src/ObjectStore/utils.ts b/packages/core/src/binary-data/object-store/utils.ts similarity index 100% rename from packages/core/src/ObjectStore/utils.ts rename to packages/core/src/binary-data/object-store/utils.ts diff --git a/packages/core/src/BinaryData/types.ts b/packages/core/src/binary-data/types.ts similarity index 100% rename from packages/core/src/BinaryData/types.ts rename to packages/core/src/binary-data/types.ts diff --git a/packages/core/src/BinaryData/utils.ts b/packages/core/src/binary-data/utils.ts similarity index 100% rename from packages/core/src/BinaryData/utils.ts rename to packages/core/src/binary-data/utils.ts diff --git a/packages/core/src/Constants.ts b/packages/core/src/constants.ts similarity index 51% rename from packages/core/src/Constants.ts rename to packages/core/src/constants.ts index 82a39b07cd..feb630e306 100644 --- a/packages/core/src/Constants.ts +++ b/packages/core/src/constants.ts @@ -1,6 +1,3 @@ -import type { INodeProperties } from 'n8n-workflow'; -import { cronNodeOptions } from 'n8n-workflow'; - const { NODE_ENV } = process.env; export const inProduction = NODE_ENV === 'production'; export const inDevelopment = !NODE_ENV || NODE_ENV === 'development'; @@ -11,38 +8,9 @@ export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__'; export const HTTP_REQUEST_NODE_TYPE = 'n8n-nodes-base.httpRequest'; export const HTTP_REQUEST_TOOL_NODE_TYPE = '@n8n/n8n-nodes-langchain.toolHttpRequest'; -export const CUSTOM_NODES_CATEGORY = 'Custom Nodes'; - export const RESTRICT_FILE_ACCESS_TO = 'N8N_RESTRICT_FILE_ACCESS_TO'; export const BLOCK_FILE_ACCESS_TO_N8N_FILES = 'N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES'; export const CONFIG_FILES = 'N8N_CONFIG_FILES'; export const BINARY_DATA_STORAGE_PATH = 'N8N_BINARY_DATA_STORAGE_PATH'; export const UM_EMAIL_TEMPLATES_INVITE = 'N8N_UM_EMAIL_TEMPLATES_INVITE'; export const UM_EMAIL_TEMPLATES_PWRESET = 'N8N_UM_EMAIL_TEMPLATES_PWRESET'; - -export const commonPollingParameters: INodeProperties[] = [ - { - displayName: 'Poll Times', - name: 'pollTimes', - type: 'fixedCollection', - typeOptions: { - multipleValues: true, - multipleValueButtonText: 'Add Poll Time', - }, - default: { item: [{ mode: 'everyMinute' }] }, - description: 'Time at which polling should occur', - placeholder: 'Add Poll Time', - options: cronNodeOptions, - }, -]; - -export const commonCORSParameters: INodeProperties[] = [ - { - displayName: 'Allowed Origins (CORS)', - name: 'allowedOrigins', - type: 'string', - default: '*', - description: - 'Comma-separated list of URLs allowed for cross-origin non-preflight requests. Use * (default) to allow all origins.', - }, -]; diff --git a/packages/core/src/Credentials.ts b/packages/core/src/credentials.ts similarity index 96% rename from packages/core/src/Credentials.ts rename to packages/core/src/credentials.ts index f5f8eb834f..9b5b4c1455 100644 --- a/packages/core/src/Credentials.ts +++ b/packages/core/src/credentials.ts @@ -2,7 +2,7 @@ import { Container } from '@n8n/di'; import type { ICredentialDataDecryptedObject, ICredentialsEncrypted } from 'n8n-workflow'; import { ApplicationError, ICredentials, jsonParse } from 'n8n-workflow'; -import { Cipher } from './Cipher'; +import { Cipher } from '@/encryption/cipher'; export class Credentials< T extends object = ICredentialDataDecryptedObject, diff --git a/packages/core/test/Cipher.test.ts b/packages/core/src/encryption/__tests__/cipher.test.ts similarity index 87% rename from packages/core/test/Cipher.test.ts rename to packages/core/src/encryption/__tests__/cipher.test.ts index 7f6bcdedf3..c1e14a9be0 100644 --- a/packages/core/test/Cipher.test.ts +++ b/packages/core/src/encryption/__tests__/cipher.test.ts @@ -1,9 +1,9 @@ import { Container } from '@n8n/di'; -import { Cipher } from '@/Cipher'; -import { InstanceSettings } from '@/InstanceSettings'; +import { InstanceSettings } from '@/instance-settings'; +import { mockInstance } from '@test/utils'; -import { mockInstance } from './utils'; +import { Cipher } from '../cipher'; describe('Cipher', () => { mockInstance(InstanceSettings, { encryptionKey: 'test_key' }); diff --git a/packages/core/src/Cipher.ts b/packages/core/src/encryption/cipher.ts similarity index 96% rename from packages/core/src/Cipher.ts rename to packages/core/src/encryption/cipher.ts index 812c8f452a..248ca0317b 100644 --- a/packages/core/src/Cipher.ts +++ b/packages/core/src/encryption/cipher.ts @@ -1,7 +1,7 @@ import { Service } from '@n8n/di'; import { createHash, createCipheriv, createDecipheriv, randomBytes } from 'crypto'; -import { InstanceSettings } from './InstanceSettings'; +import { InstanceSettings } from '@/instance-settings'; // Data encrypted by CryptoJS always starts with these bytes const RANDOM_BYTES = Buffer.from('53616c7465645f5f', 'hex'); diff --git a/packages/core/src/encryption/index.ts b/packages/core/src/encryption/index.ts new file mode 100644 index 0000000000..b2ab9e782e --- /dev/null +++ b/packages/core/src/encryption/index.ts @@ -0,0 +1 @@ +export { Cipher } from './cipher'; diff --git a/packages/core/test/error-reporter.test.ts b/packages/core/src/errors/__tests__/error-reporter.test.ts similarity index 99% rename from packages/core/test/error-reporter.test.ts rename to packages/core/src/errors/__tests__/error-reporter.test.ts index e5b51d3356..ff260ea1f7 100644 --- a/packages/core/test/error-reporter.test.ts +++ b/packages/core/src/errors/__tests__/error-reporter.test.ts @@ -4,9 +4,10 @@ import { AxiosError } from 'axios'; import { mock } from 'jest-mock-extended'; import { ApplicationError } from 'n8n-workflow'; -import { ErrorReporter } from '@/error-reporter'; import type { Logger } from '@/logging/logger'; +import { ErrorReporter } from '../error-reporter'; + jest.mock('@sentry/node', () => ({ init: jest.fn(), setTag: jest.fn(), diff --git a/packages/core/src/error-reporter.ts b/packages/core/src/errors/error-reporter.ts similarity index 98% rename from packages/core/src/error-reporter.ts rename to packages/core/src/errors/error-reporter.ts index 5444b0d410..3c0bffcd4b 100644 --- a/packages/core/src/error-reporter.ts +++ b/packages/core/src/errors/error-reporter.ts @@ -6,8 +6,8 @@ import { AxiosError } from 'axios'; import { ApplicationError, ExecutionCancelledError, type ReportingOptions } from 'n8n-workflow'; import { createHash } from 'node:crypto'; -import type { InstanceType } from './InstanceSettings'; -import { Logger } from './logging/logger'; +import type { InstanceType } from '@/instance-settings'; +import { Logger } from '@/logging/logger'; type ErrorReporterInitOptions = { serverType: InstanceType | 'task_runner'; diff --git a/packages/core/src/errors/index.ts b/packages/core/src/errors/index.ts index 38cd481c25..b6571dd336 100644 --- a/packages/core/src/errors/index.ts +++ b/packages/core/src/errors/index.ts @@ -5,3 +5,5 @@ export { InvalidManagerError } from './invalid-manager.error'; export { InvalidExecutionMetadataError } from './invalid-execution-metadata.error'; export { UnrecognizedCredentialTypeError } from './unrecognized-credential-type.error'; export { UnrecognizedNodeTypeError } from './unrecognized-node-type.error'; + +export { ErrorReporter } from './error-reporter'; diff --git a/packages/core/src/errors/invalid-mode.error.ts b/packages/core/src/errors/invalid-mode.error.ts index 179582911a..1e3a4f3594 100644 --- a/packages/core/src/errors/invalid-mode.error.ts +++ b/packages/core/src/errors/invalid-mode.error.ts @@ -1,6 +1,6 @@ import { ApplicationError } from 'n8n-workflow'; -import { CONFIG_MODES } from '../BinaryData/utils'; +import { CONFIG_MODES } from '../binary-data/utils'; export class InvalidModeError extends ApplicationError { constructor() { diff --git a/packages/core/src/errors/workflow-has-issues.error.ts b/packages/core/src/errors/workflow-has-issues.error.ts new file mode 100644 index 0000000000..77f73131c2 --- /dev/null +++ b/packages/core/src/errors/workflow-has-issues.error.ts @@ -0,0 +1,7 @@ +import { WorkflowOperationError } from 'n8n-workflow'; + +export class WorkflowHasIssuesError extends WorkflowOperationError { + constructor() { + super('The workflow has issues and cannot be executed for that reason. Please fix them first.'); + } +} diff --git a/packages/core/src/__tests__/ActiveWorkflows.test.ts b/packages/core/src/execution-engine/__tests__/active-workflows.test.ts similarity index 96% rename from packages/core/src/__tests__/ActiveWorkflows.test.ts rename to packages/core/src/execution-engine/__tests__/active-workflows.test.ts index 410b4779ba..2d96a36488 100644 --- a/packages/core/src/__tests__/ActiveWorkflows.test.ts +++ b/packages/core/src/execution-engine/__tests__/active-workflows.test.ts @@ -12,11 +12,12 @@ import type { } from 'n8n-workflow'; import { LoggerProxy, TriggerCloseError, WorkflowActivationError } from 'n8n-workflow'; -import { ActiveWorkflows } from '@/ActiveWorkflows'; -import type { ErrorReporter } from '@/error-reporter'; -import type { PollContext } from '@/node-execution-context'; -import type { ScheduledTaskManager } from '@/ScheduledTaskManager'; -import type { TriggersAndPollers } from '@/TriggersAndPollers'; +import type { ErrorReporter } from '@/errors/error-reporter'; + +import { ActiveWorkflows } from '../active-workflows'; +import type { PollContext } from '../node-execution-context'; +import type { ScheduledTaskManager } from '../scheduled-task-manager'; +import type { TriggersAndPollers } from '../triggers-and-pollers'; describe('ActiveWorkflows', () => { const workflowId = 'test-workflow-id'; diff --git a/packages/core/test/RoutingNode.test.ts b/packages/core/src/execution-engine/__tests__/routing-node.test.ts similarity index 99% rename from packages/core/test/RoutingNode.test.ts rename to packages/core/src/execution-engine/__tests__/routing-node.test.ts index 45ef937803..5203cff3da 100644 --- a/packages/core/test/RoutingNode.test.ts +++ b/packages/core/src/execution-engine/__tests__/routing-node.test.ts @@ -21,10 +21,10 @@ import type { } from 'n8n-workflow'; import { NodeHelpers, Workflow } from 'n8n-workflow'; -import * as executionContexts from '@/node-execution-context'; -import { RoutingNode } from '@/RoutingNode'; +import * as executionContexts from '@/execution-engine/node-execution-context'; +import { NodeTypes } from '@test/helpers'; -import { NodeTypes } from './helpers'; +import { RoutingNode } from '../routing-node'; const postReceiveFunction1 = async function ( this: IExecuteSingleFunctions, diff --git a/packages/core/test/ScheduledTaskManager.test.ts b/packages/core/src/execution-engine/__tests__/scheduled-task-manager.test.ts similarity index 94% rename from packages/core/test/ScheduledTaskManager.test.ts rename to packages/core/src/execution-engine/__tests__/scheduled-task-manager.test.ts index 5166240856..4c39d3afbc 100644 --- a/packages/core/test/ScheduledTaskManager.test.ts +++ b/packages/core/src/execution-engine/__tests__/scheduled-task-manager.test.ts @@ -1,8 +1,9 @@ import { mock } from 'jest-mock-extended'; import type { Workflow } from 'n8n-workflow'; -import type { InstanceSettings } from '@/InstanceSettings'; -import { ScheduledTaskManager } from '@/ScheduledTaskManager'; +import type { InstanceSettings } from '@/instance-settings'; + +import { ScheduledTaskManager } from '../scheduled-task-manager'; describe('ScheduledTaskManager', () => { const instanceSettings = mock({ isLeader: true }); diff --git a/packages/core/test/SSHClientsManager.test.ts b/packages/core/src/execution-engine/__tests__/ssh-clients-manager.test.ts similarity index 97% rename from packages/core/test/SSHClientsManager.test.ts rename to packages/core/src/execution-engine/__tests__/ssh-clients-manager.test.ts index 132a54baef..d58fe22802 100644 --- a/packages/core/test/SSHClientsManager.test.ts +++ b/packages/core/src/execution-engine/__tests__/ssh-clients-manager.test.ts @@ -1,7 +1,7 @@ import type { SSHCredentials } from 'n8n-workflow'; import { Client } from 'ssh2'; -import { SSHClientsManager } from '@/SSHClientsManager'; +import { SSHClientsManager } from '../ssh-clients-manager'; describe('SSHClientsManager', () => { const credentials: SSHCredentials = { diff --git a/packages/core/test/TriggersAndPollers.test.ts b/packages/core/src/execution-engine/__tests__/triggers-and-pollers.test.ts similarity index 98% rename from packages/core/test/TriggersAndPollers.test.ts rename to packages/core/src/execution-engine/__tests__/triggers-and-pollers.test.ts index 27cc8b47d9..0b7b9cd4a4 100644 --- a/packages/core/test/TriggersAndPollers.test.ts +++ b/packages/core/src/execution-engine/__tests__/triggers-and-pollers.test.ts @@ -13,7 +13,7 @@ import type { IRun, } from 'n8n-workflow'; -import { TriggersAndPollers } from '@/TriggersAndPollers'; +import { TriggersAndPollers } from '../triggers-and-pollers'; describe('TriggersAndPollers', () => { const node = mock(); diff --git a/packages/core/test/WorkflowExecute.test.ts b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts similarity index 98% rename from packages/core/test/WorkflowExecute.test.ts rename to packages/core/src/execution-engine/__tests__/workflow-execute.test.ts index 6ab3afdaeb..c5924e26f6 100644 --- a/packages/core/test/WorkflowExecute.test.ts +++ b/packages/core/src/execution-engine/__tests__/workflow-execute.test.ts @@ -1,3 +1,7 @@ +// Disable task runners until we have fixed the "run test workflows" test +// to mock the Code Node execution +process.env.N8N_RUNNERS_ENABLED = 'false'; + // NOTE: Diagrams in this file have been created with https://asciiflow.com/#/ // If you update the tests, please update the diagrams as well. // If you add a test, please create a new diagram. @@ -37,13 +41,13 @@ import { Workflow, } from 'n8n-workflow'; -import { DirectedGraph } from '@/PartialExecutionUtils'; -import * as partialExecutionUtils from '@/PartialExecutionUtils'; -import { createNodeData, toITaskData } from '@/PartialExecutionUtils/__tests__/helpers'; -import { WorkflowExecute } from '@/WorkflowExecute'; +import * as Helpers from '@test/helpers'; +import { legacyWorkflowExecuteTests, v1WorkflowExecuteTests } from '@test/helpers/constants'; -import * as Helpers from './helpers'; -import { legacyWorkflowExecuteTests, v1WorkflowExecuteTests } from './helpers/constants'; +import { DirectedGraph } from '../partial-execution-utils'; +import * as partialExecutionUtils from '../partial-execution-utils'; +import { createNodeData, toITaskData } from '../partial-execution-utils/__tests__/helpers'; +import { WorkflowExecute } from '../workflow-execute'; const nodeTypes = Helpers.NodeTypes(); diff --git a/packages/core/test/workflows/error_outputs.json b/packages/core/src/execution-engine/__tests__/workflows/error_outputs.json similarity index 100% rename from packages/core/test/workflows/error_outputs.json rename to packages/core/src/execution-engine/__tests__/workflows/error_outputs.json diff --git a/packages/core/test/workflows/paired_items_fix.json b/packages/core/src/execution-engine/__tests__/workflows/paired_items_fix.json similarity index 100% rename from packages/core/test/workflows/paired_items_fix.json rename to packages/core/src/execution-engine/__tests__/workflows/paired_items_fix.json diff --git a/packages/core/src/ActiveWorkflows.ts b/packages/core/src/execution-engine/active-workflows.ts similarity index 95% rename from packages/core/src/ActiveWorkflows.ts rename to packages/core/src/execution-engine/active-workflows.ts index abd544956a..50aa3853be 100644 --- a/packages/core/src/ActiveWorkflows.ts +++ b/packages/core/src/execution-engine/active-workflows.ts @@ -18,11 +18,12 @@ import { WorkflowDeactivationError, } from 'n8n-workflow'; -import { ErrorReporter } from './error-reporter'; -import type { IWorkflowData } from './Interfaces'; -import { Logger } from './logging/logger'; -import { ScheduledTaskManager } from './ScheduledTaskManager'; -import { TriggersAndPollers } from './TriggersAndPollers'; +import { ErrorReporter } from '@/errors/error-reporter'; +import type { IWorkflowData } from '@/interfaces'; +import { Logger } from '@/logging/logger'; + +import { ScheduledTaskManager } from './scheduled-task-manager'; +import { TriggersAndPollers } from './triggers-and-pollers'; @Service() export class ActiveWorkflows { diff --git a/packages/core/src/execution-engine/index.ts b/packages/core/src/execution-engine/index.ts new file mode 100644 index 0000000000..1523e93a11 --- /dev/null +++ b/packages/core/src/execution-engine/index.ts @@ -0,0 +1,6 @@ +export * from './active-workflows'; +export * from './routing-node'; +export * from './node-execution-context'; +export * from './partial-execution-utils'; +export * from './node-execution-context/utils/execution-metadata'; +export * from './workflow-execute'; diff --git a/packages/core/src/node-execution-context/__tests__/execute-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/execute-context.test.ts similarity index 100% rename from packages/core/src/node-execution-context/__tests__/execute-context.test.ts rename to packages/core/src/execution-engine/node-execution-context/__tests__/execute-context.test.ts diff --git a/packages/core/src/node-execution-context/__tests__/execute-single-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/execute-single-context.test.ts similarity index 100% rename from packages/core/src/node-execution-context/__tests__/execute-single-context.test.ts rename to packages/core/src/execution-engine/node-execution-context/__tests__/execute-single-context.test.ts diff --git a/packages/core/src/node-execution-context/__tests__/hook-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/hook-context.test.ts similarity index 100% rename from packages/core/src/node-execution-context/__tests__/hook-context.test.ts rename to packages/core/src/execution-engine/node-execution-context/__tests__/hook-context.test.ts diff --git a/packages/core/src/node-execution-context/__tests__/load-options-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/load-options-context.test.ts similarity index 100% rename from packages/core/src/node-execution-context/__tests__/load-options-context.test.ts rename to packages/core/src/execution-engine/node-execution-context/__tests__/load-options-context.test.ts diff --git a/packages/core/src/node-execution-context/__tests__/node-execution-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/node-execution-context.test.ts similarity index 99% rename from packages/core/src/node-execution-context/__tests__/node-execution-context.test.ts rename to packages/core/src/execution-engine/node-execution-context/__tests__/node-execution-context.test.ts index a0a368df4c..6a10c2bba5 100644 --- a/packages/core/src/node-execution-context/__tests__/node-execution-context.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/__tests__/node-execution-context.test.ts @@ -12,7 +12,7 @@ import type { } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow'; -import { InstanceSettings } from '@/InstanceSettings'; +import { InstanceSettings } from '@/instance-settings'; import { NodeExecutionContext } from '../node-execution-context'; diff --git a/packages/core/src/node-execution-context/__tests__/poll-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/poll-context.test.ts similarity index 100% rename from packages/core/src/node-execution-context/__tests__/poll-context.test.ts rename to packages/core/src/execution-engine/node-execution-context/__tests__/poll-context.test.ts diff --git a/packages/core/src/node-execution-context/__tests__/shared-tests.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/shared-tests.ts similarity index 98% rename from packages/core/src/node-execution-context/__tests__/shared-tests.ts rename to packages/core/src/execution-engine/node-execution-context/__tests__/shared-tests.ts index 8b1d23930b..601b49fe4c 100644 --- a/packages/core/src/node-execution-context/__tests__/shared-tests.ts +++ b/packages/core/src/execution-engine/node-execution-context/__tests__/shared-tests.ts @@ -17,7 +17,7 @@ import type { } from 'n8n-workflow'; import { ApplicationError, NodeHelpers, WAIT_INDEFINITELY } from 'n8n-workflow'; -import { BinaryDataService } from '@/BinaryData/BinaryData.service'; +import { BinaryDataService } from '@/binary-data/binary-data.service'; import type { BaseExecuteContext } from '../base-execute-context'; diff --git a/packages/core/src/node-execution-context/__tests__/supply-data-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/supply-data-context.test.ts similarity index 100% rename from packages/core/src/node-execution-context/__tests__/supply-data-context.test.ts rename to packages/core/src/execution-engine/node-execution-context/__tests__/supply-data-context.test.ts diff --git a/packages/core/src/node-execution-context/__tests__/trigger-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/trigger-context.test.ts similarity index 100% rename from packages/core/src/node-execution-context/__tests__/trigger-context.test.ts rename to packages/core/src/execution-engine/node-execution-context/__tests__/trigger-context.test.ts diff --git a/packages/core/src/node-execution-context/__tests__/webhook-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/webhook-context.test.ts similarity index 100% rename from packages/core/src/node-execution-context/__tests__/webhook-context.test.ts rename to packages/core/src/execution-engine/node-execution-context/__tests__/webhook-context.test.ts diff --git a/packages/core/src/node-execution-context/base-execute-context.ts b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts similarity index 98% rename from packages/core/src/node-execution-context/base-execute-context.ts rename to packages/core/src/execution-engine/node-execution-context/base-execute-context.ts index 0416870344..1c4bba34e3 100644 --- a/packages/core/src/node-execution-context/base-execute-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/base-execute-context.ts @@ -29,7 +29,7 @@ import { WorkflowDataProxy, } from 'n8n-workflow'; -import { BinaryDataService } from '@/BinaryData/BinaryData.service'; +import { BinaryDataService } from '@/binary-data/binary-data.service'; import { NodeExecutionContext } from './node-execution-context'; diff --git a/packages/core/src/node-execution-context/execute-context.ts b/packages/core/src/execution-engine/node-execution-context/execute-context.ts similarity index 97% rename from packages/core/src/node-execution-context/execute-context.ts rename to packages/core/src/execution-engine/node-execution-context/execute-context.ts index 089c3f500a..7b3ec16bf5 100644 --- a/packages/core/src/node-execution-context/execute-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/execute-context.ts @@ -37,10 +37,10 @@ import { getFileSystemHelperFunctions, getCheckProcessedHelperFunctions, detectBinaryEncoding, -} from '@/NodeExecuteFunctions'; +} from '@/node-execute-functions'; import { BaseExecuteContext } from './base-execute-context'; -import { getInputConnectionData } from './utils/getInputConnectionData'; +import { getInputConnectionData } from './utils/get-input-connection-data'; export class ExecuteContext extends BaseExecuteContext implements IExecuteFunctions { readonly helpers: IExecuteFunctions['helpers']; diff --git a/packages/core/src/node-execution-context/execute-single-context.ts b/packages/core/src/execution-engine/node-execution-context/execute-single-context.ts similarity index 98% rename from packages/core/src/node-execution-context/execute-single-context.ts rename to packages/core/src/execution-engine/node-execution-context/execute-single-context.ts index af837a12c5..2ba450b593 100644 --- a/packages/core/src/node-execution-context/execute-single-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/execute-single-context.ts @@ -21,7 +21,7 @@ import { getBinaryHelperFunctions, getRequestHelperFunctions, returnJsonArray, -} from '@/NodeExecuteFunctions'; +} from '@/node-execute-functions'; import { BaseExecuteContext } from './base-execute-context'; diff --git a/packages/core/src/node-execution-context/hook-context.ts b/packages/core/src/execution-engine/node-execution-context/hook-context.ts similarity index 97% rename from packages/core/src/node-execution-context/hook-context.ts rename to packages/core/src/execution-engine/node-execution-context/hook-context.ts index 102be563a1..28dbac802c 100644 --- a/packages/core/src/node-execution-context/hook-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/hook-context.ts @@ -16,7 +16,7 @@ import { getNodeWebhookUrl, getRequestHelperFunctions, getWebhookDescription, -} from '@/NodeExecuteFunctions'; +} from '@/node-execute-functions'; import { NodeExecutionContext } from './node-execution-context'; diff --git a/packages/core/src/node-execution-context/index.ts b/packages/core/src/execution-engine/node-execution-context/index.ts similarity index 90% rename from packages/core/src/node-execution-context/index.ts rename to packages/core/src/execution-engine/node-execution-context/index.ts index c3bcebbd44..4c96322211 100644 --- a/packages/core/src/node-execution-context/index.ts +++ b/packages/core/src/execution-engine/node-execution-context/index.ts @@ -10,4 +10,4 @@ export { SupplyDataContext } from './supply-data-context'; export { TriggerContext } from './trigger-context'; export { WebhookContext } from './webhook-context'; -export { getAdditionalKeys } from './utils/getAdditionalKeys'; +export { getAdditionalKeys } from './utils/get-additional-keys'; diff --git a/packages/core/src/node-execution-context/load-options-context.ts b/packages/core/src/execution-engine/node-execution-context/load-options-context.ts similarity index 95% rename from packages/core/src/node-execution-context/load-options-context.ts rename to packages/core/src/execution-engine/node-execution-context/load-options-context.ts index c961b56c06..30c31eacce 100644 --- a/packages/core/src/node-execution-context/load-options-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/load-options-context.ts @@ -9,11 +9,11 @@ import type { Workflow, } from 'n8n-workflow'; -import { extractValue } from '@/ExtractValue'; // eslint-disable-next-line import/no-cycle -import { getRequestHelperFunctions, getSSHTunnelFunctions } from '@/NodeExecuteFunctions'; +import { getRequestHelperFunctions, getSSHTunnelFunctions } from '@/node-execute-functions'; import { NodeExecutionContext } from './node-execution-context'; +import { extractValue } from './utils/extract-value'; export class LoadOptionsContext extends NodeExecutionContext implements ILoadOptionsFunctions { readonly helpers: ILoadOptionsFunctions['helpers']; diff --git a/packages/core/src/node-execution-context/local-load-options-context.ts b/packages/core/src/execution-engine/node-execution-context/local-load-options-context.ts similarity index 100% rename from packages/core/src/node-execution-context/local-load-options-context.ts rename to packages/core/src/execution-engine/node-execution-context/local-load-options-context.ts diff --git a/packages/core/src/node-execution-context/node-execution-context.ts b/packages/core/src/execution-engine/node-execution-context/node-execution-context.ts similarity index 94% rename from packages/core/src/node-execution-context/node-execution-context.ts rename to packages/core/src/execution-engine/node-execution-context/node-execution-context.ts index 45a205637c..79225554d6 100644 --- a/packages/core/src/node-execution-context/node-execution-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/node-execution-context.ts @@ -28,16 +28,16 @@ import { NodeOperationError, } from 'n8n-workflow'; -import { HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE } from '@/Constants'; +import { HTTP_REQUEST_NODE_TYPE, HTTP_REQUEST_TOOL_NODE_TYPE } from '@/constants'; import { Memoized } from '@/decorators'; -import { extractValue } from '@/ExtractValue'; -import { InstanceSettings } from '@/InstanceSettings'; +import { InstanceSettings } from '@/instance-settings'; import { Logger } from '@/logging/logger'; -import { cleanupParameterData } from './utils/cleanupParameterData'; -import { ensureType } from './utils/ensureType'; -import { getAdditionalKeys } from './utils/getAdditionalKeys'; -import { validateValueAgainstSchema } from './utils/validateValueAgainstSchema'; +import { cleanupParameterData } from './utils/cleanup-parameter-data'; +import { ensureType } from './utils/ensure-type'; +import { extractValue } from './utils/extract-value'; +import { getAdditionalKeys } from './utils/get-additional-keys'; +import { validateValueAgainstSchema } from './utils/validate-value-against-schema'; export abstract class NodeExecutionContext implements Omit { protected readonly instanceSettings = Container.get(InstanceSettings); @@ -79,18 +79,24 @@ export abstract class NodeExecutionContext implements Omit { it('should stringify Luxon dates in-place', () => { diff --git a/packages/core/test/CreateNodeAsTool.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/create-node-as-tool.test.ts similarity index 99% rename from packages/core/test/CreateNodeAsTool.test.ts rename to packages/core/src/execution-engine/node-execution-context/utils/__tests__/create-node-as-tool.test.ts index fdc14269e1..450ec99247 100644 --- a/packages/core/test/CreateNodeAsTool.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/create-node-as-tool.test.ts @@ -2,7 +2,7 @@ import { mock } from 'jest-mock-extended'; import type { INodeType, ISupplyDataFunctions, INode } from 'n8n-workflow'; import { z } from 'zod'; -import { createNodeAsTool } from '@/CreateNodeAsTool'; +import { createNodeAsTool } from '../create-node-as-tool'; jest.mock('@langchain/core/tools', () => ({ DynamicStructuredTool: jest.fn().mockImplementation((config) => ({ diff --git a/packages/core/src/node-execution-context/utils/__tests__/ensureType.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/ensure-type.test.ts similarity index 98% rename from packages/core/src/node-execution-context/utils/__tests__/ensureType.test.ts rename to packages/core/src/execution-engine/node-execution-context/utils/__tests__/ensure-type.test.ts index 1637d988c9..374b3d43bc 100644 --- a/packages/core/src/node-execution-context/utils/__tests__/ensureType.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/ensure-type.test.ts @@ -1,6 +1,6 @@ import { ExpressionError } from 'n8n-workflow'; -import { ensureType } from '../ensureType'; +import { ensureType } from '../ensure-type'; describe('ensureType', () => { it('throws error for null value', () => { diff --git a/packages/core/test/WorkflowExecutionMetadata.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/execution-metadata.test.ts similarity index 99% rename from packages/core/test/WorkflowExecutionMetadata.test.ts rename to packages/core/src/execution-engine/node-execution-context/utils/__tests__/execution-metadata.test.ts index 63d0892e6a..c673a2fcfe 100644 --- a/packages/core/test/WorkflowExecutionMetadata.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/execution-metadata.test.ts @@ -1,13 +1,14 @@ import type { IRunExecutionData } from 'n8n-workflow'; import { InvalidExecutionMetadataError } from '@/errors/invalid-execution-metadata.error'; + import { setWorkflowExecutionMetadata, setAllWorkflowExecutionMetadata, KV_LIMIT, getWorkflowExecutionMetadata, getAllWorkflowExecutionMetadata, -} from '@/ExecutionMetadata'; +} from '../execution-metadata'; describe('Execution Metadata functions', () => { test('setWorkflowExecutionMetadata will set a value', () => { diff --git a/packages/core/src/node-execution-context/utils/__tests__/getAdditionalKeys.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/get-additional-keys.test.ts similarity index 97% rename from packages/core/src/node-execution-context/utils/__tests__/getAdditionalKeys.test.ts rename to packages/core/src/execution-engine/node-execution-context/utils/__tests__/get-additional-keys.test.ts index 6ac1fbdc07..b78de39a6d 100644 --- a/packages/core/src/node-execution-context/utils/__tests__/getAdditionalKeys.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/get-additional-keys.test.ts @@ -7,9 +7,9 @@ import type { SecretsHelpersBase, } from 'n8n-workflow'; -import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/Constants'; +import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/constants'; -import { getAdditionalKeys } from '../getAdditionalKeys'; +import { getAdditionalKeys } from '../get-additional-keys'; describe('getAdditionalKeys', () => { const secretsHelpers = mock(); diff --git a/packages/core/src/node-execution-context/utils/__tests__/getInputConnectionData.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/get-input-connection-data.test.ts similarity index 100% rename from packages/core/src/node-execution-context/utils/__tests__/getInputConnectionData.test.ts rename to packages/core/src/execution-engine/node-execution-context/utils/__tests__/get-input-connection-data.test.ts diff --git a/packages/core/src/node-execution-context/utils/__tests__/validateValueAgainstSchema.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/validate-value-against-schema.test.ts similarity index 98% rename from packages/core/src/node-execution-context/utils/__tests__/validateValueAgainstSchema.test.ts rename to packages/core/src/execution-engine/node-execution-context/utils/__tests__/validate-value-against-schema.test.ts index d4b96fa41d..4f389856ad 100644 --- a/packages/core/src/node-execution-context/utils/__tests__/validateValueAgainstSchema.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/validate-value-against-schema.test.ts @@ -1,6 +1,6 @@ import type { IDataObject, INode, INodeType } from 'n8n-workflow'; -import { validateValueAgainstSchema } from '../validateValueAgainstSchema'; +import { validateValueAgainstSchema } from '../validate-value-against-schema'; describe('validateValueAgainstSchema', () => { test('should validate fixedCollection values parameter', () => { diff --git a/packages/core/src/node-execution-context/utils/cleanupParameterData.ts b/packages/core/src/execution-engine/node-execution-context/utils/cleanup-parameter-data.ts similarity index 100% rename from packages/core/src/node-execution-context/utils/cleanupParameterData.ts rename to packages/core/src/execution-engine/node-execution-context/utils/cleanup-parameter-data.ts diff --git a/packages/core/src/CreateNodeAsTool.ts b/packages/core/src/execution-engine/node-execution-context/utils/create-node-as-tool.ts similarity index 100% rename from packages/core/src/CreateNodeAsTool.ts rename to packages/core/src/execution-engine/node-execution-context/utils/create-node-as-tool.ts diff --git a/packages/core/src/node-execution-context/utils/ensureType.ts b/packages/core/src/execution-engine/node-execution-context/utils/ensure-type.ts similarity index 100% rename from packages/core/src/node-execution-context/utils/ensureType.ts rename to packages/core/src/execution-engine/node-execution-context/utils/ensure-type.ts diff --git a/packages/core/src/ExecutionMetadata.ts b/packages/core/src/execution-engine/node-execution-context/utils/execution-metadata.ts similarity index 97% rename from packages/core/src/ExecutionMetadata.ts rename to packages/core/src/execution-engine/node-execution-context/utils/execution-metadata.ts index 8466933e05..5e7c4954d6 100644 --- a/packages/core/src/ExecutionMetadata.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/execution-metadata.ts @@ -1,7 +1,7 @@ import type { IRunExecutionData } from 'n8n-workflow'; import { LoggerProxy as Logger } from 'n8n-workflow'; -import { InvalidExecutionMetadataError } from './errors/invalid-execution-metadata.error'; +import { InvalidExecutionMetadataError } from '@/errors/invalid-execution-metadata.error'; export const KV_LIMIT = 10; diff --git a/packages/core/src/ExtractValue.ts b/packages/core/src/execution-engine/node-execution-context/utils/extract-value.ts similarity index 100% rename from packages/core/src/ExtractValue.ts rename to packages/core/src/execution-engine/node-execution-context/utils/extract-value.ts diff --git a/packages/core/src/node-execution-context/utils/getAdditionalKeys.ts b/packages/core/src/execution-engine/node-execution-context/utils/get-additional-keys.ts similarity index 93% rename from packages/core/src/node-execution-context/utils/getAdditionalKeys.ts rename to packages/core/src/execution-engine/node-execution-context/utils/get-additional-keys.ts index 28bf3b89f6..7e18d0f2d1 100644 --- a/packages/core/src/node-execution-context/utils/getAdditionalKeys.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/get-additional-keys.ts @@ -6,14 +6,15 @@ import type { } from 'n8n-workflow'; import { LoggerProxy } from 'n8n-workflow'; -import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/Constants'; +import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/constants'; + import { setWorkflowExecutionMetadata, setAllWorkflowExecutionMetadata, getWorkflowExecutionMetadata, getAllWorkflowExecutionMetadata, -} from '@/ExecutionMetadata'; -import { getSecretsProxy } from '@/Secrets'; +} from './execution-metadata'; +import { getSecretsProxy } from './get-secrets-proxy'; /** Returns the additional keys for Expressions and Function-Nodes */ export function getAdditionalKeys( diff --git a/packages/core/src/node-execution-context/utils/getInputConnectionData.ts b/packages/core/src/execution-engine/node-execution-context/utils/get-input-connection-data.ts similarity index 96% rename from packages/core/src/node-execution-context/utils/getInputConnectionData.ts rename to packages/core/src/execution-engine/node-execution-context/utils/get-input-connection-data.ts index 231f672d65..9f76d0e106 100644 --- a/packages/core/src/node-execution-context/utils/getInputConnectionData.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/get-input-connection-data.ts @@ -19,10 +19,10 @@ import { ApplicationError, } from 'n8n-workflow'; -import { createNodeAsTool } from '@/CreateNodeAsTool'; +import { createNodeAsTool } from './create-node-as-tool'; // eslint-disable-next-line import/no-cycle -import { SupplyDataContext } from '@/node-execution-context'; -import type { ExecuteContext, WebhookContext } from '@/node-execution-context'; +import { SupplyDataContext } from '../../node-execution-context'; +import type { ExecuteContext, WebhookContext } from '../../node-execution-context'; export async function getInputConnectionData( this: ExecuteContext | WebhookContext | SupplyDataContext, diff --git a/packages/core/src/Secrets.ts b/packages/core/src/execution-engine/node-execution-context/utils/get-secrets-proxy.ts similarity index 100% rename from packages/core/src/Secrets.ts rename to packages/core/src/execution-engine/node-execution-context/utils/get-secrets-proxy.ts diff --git a/packages/core/src/node-execution-context/utils/validateValueAgainstSchema.ts b/packages/core/src/execution-engine/node-execution-context/utils/validate-value-against-schema.ts similarity index 98% rename from packages/core/src/node-execution-context/utils/validateValueAgainstSchema.ts rename to packages/core/src/execution-engine/node-execution-context/utils/validate-value-against-schema.ts index a4c1b211e7..58744adf03 100644 --- a/packages/core/src/node-execution-context/utils/validateValueAgainstSchema.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/validate-value-against-schema.ts @@ -14,7 +14,7 @@ import { validateFieldType, } from 'n8n-workflow'; -import type { ExtendedValidationResult } from '@/Interfaces'; +import type { ExtendedValidationResult } from '@/interfaces'; const validateResourceMapperValue = ( parameterName: string, diff --git a/packages/core/src/node-execution-context/webhook-context.ts b/packages/core/src/execution-engine/node-execution-context/webhook-context.ts similarity index 97% rename from packages/core/src/node-execution-context/webhook-context.ts rename to packages/core/src/execution-engine/node-execution-context/webhook-context.ts index 9d131a4103..d5b55cffb6 100644 --- a/packages/core/src/node-execution-context/webhook-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/webhook-context.ts @@ -25,10 +25,10 @@ import { getNodeWebhookUrl, getRequestHelperFunctions, returnJsonArray, -} from '@/NodeExecuteFunctions'; +} from '@/node-execute-functions'; import { NodeExecutionContext } from './node-execution-context'; -import { getInputConnectionData } from './utils/getInputConnectionData'; +import { getInputConnectionData } from './utils/get-input-connection-data'; export class WebhookContext extends NodeExecutionContext implements IWebhookFunctions { readonly helpers: IWebhookFunctions['helpers']; diff --git a/packages/core/src/node-execution-context/workflow-node-context.ts b/packages/core/src/execution-engine/node-execution-context/workflow-node-context.ts similarity index 100% rename from packages/core/src/node-execution-context/workflow-node-context.ts rename to packages/core/src/execution-engine/node-execution-context/workflow-node-context.ts diff --git a/packages/core/src/execution-engine/partial-execution-utils/__tests__/clean-run-data.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/clean-run-data.test.ts new file mode 100644 index 0000000000..1e46e40070 --- /dev/null +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/clean-run-data.test.ts @@ -0,0 +1,234 @@ +// NOTE: Diagrams in this file have been created with https://asciiflow.com/#/ +// If you update the tests, please update the diagrams as well. +// If you add a test, please create a new diagram. +// +// Map +// 0 means the output has no run data +// 1 means the output has run data +// ►► denotes the node that the user wants to execute to +// XX denotes that the node is disabled +// PD denotes that the node has pinned data + +import { NodeConnectionType, type IRunData } from 'n8n-workflow'; + +import { createNodeData, toITaskData } from './helpers'; +import { cleanRunData } from '../clean-run-data'; +import { DirectedGraph } from '../directed-graph'; + +describe('cleanRunData', () => { + // ┌─────┐ ┌─────┐ ┌─────┐ + // │node1├───►│node2├──►│node3│ + // └─────┘ └─────┘ └─────┘ + test('deletes all run data of all children and the node being passed in', () => { + // ARRANGE + const node1 = createNodeData({ name: 'Node1' }); + const node2 = createNodeData({ name: 'Node2' }); + const node3 = createNodeData({ name: 'Node3' }); + const graph = new DirectedGraph() + .addNodes(node1, node2, node3) + .addConnections({ from: node1, to: node2 }, { from: node2, to: node3 }); + const runData: IRunData = { + [node1.name]: [toITaskData([{ data: { value: 1 } }])], + [node2.name]: [toITaskData([{ data: { value: 2 } }])], + [node3.name]: [toITaskData([{ data: { value: 3 } }])], + }; + + // ACT + const newRunData = cleanRunData(runData, graph, new Set([node1])); + + // ASSERT + expect(newRunData).toEqual({}); + }); + + // ┌─────┐ ┌─────┐ ┌─────┐ + // │node1├───►│node2├──►│node3│ + // └─────┘ └─────┘ └─────┘ + test('retains the run data of parent nodes of the node being passed in', () => { + // ARRANGE + const node1 = createNodeData({ name: 'Node1' }); + const node2 = createNodeData({ name: 'Node2' }); + const node3 = createNodeData({ name: 'Node3' }); + const graph = new DirectedGraph() + .addNodes(node1, node2, node3) + .addConnections({ from: node1, to: node2 }, { from: node2, to: node3 }); + const runData: IRunData = { + [node1.name]: [toITaskData([{ data: { value: 1 } }])], + [node2.name]: [toITaskData([{ data: { value: 2 } }])], + [node3.name]: [toITaskData([{ data: { value: 3 } }])], + }; + + // ACT + const newRunData = cleanRunData(runData, graph, new Set([node2])); + + // ASSERT + expect(newRunData).toEqual({ [node1.name]: runData[node1.name] }); + }); + + // ┌─────┐ ┌─────┐ ┌─────┐ + // ┌─►│node1├───►│node2├──►│node3├─┐ + // │ └─────┘ └─────┘ └─────┘ │ + // │ │ + // └───────────────────────────────┘ + test('terminates when finding a cycle', () => { + // ARRANGE + const node1 = createNodeData({ name: 'Node1' }); + const node2 = createNodeData({ name: 'Node2' }); + const node3 = createNodeData({ name: 'Node3' }); + const graph = new DirectedGraph() + .addNodes(node1, node2, node3) + .addConnections( + { from: node1, to: node2 }, + { from: node2, to: node3 }, + { from: node3, to: node1 }, + ); + + const runData: IRunData = { + [node1.name]: [toITaskData([{ data: { value: 1 } }])], + [node2.name]: [toITaskData([{ data: { value: 2 } }])], + [node3.name]: [toITaskData([{ data: { value: 3 } }])], + }; + + // ACT + const newRunData = cleanRunData(runData, graph, new Set([node2])); + + // ASSERT + // TODO: Find out if this is a desirable result in milestone 2 + expect(newRunData).toEqual({}); + }); + + // ┌─────┐ ┌─────┐ + // │node1├───►│node2│ + // └─────┘ └─────┘ + test('removes run data of nodes that are not in the subgraph', () => { + // ARRANGE + const node1 = createNodeData({ name: 'Node1' }); + const node2 = createNodeData({ name: 'Node2' }); + const graph = new DirectedGraph() + .addNodes(node1, node2) + .addConnections({ from: node1, to: node2 }); + // not part of the graph + const node3 = createNodeData({ name: 'Node3' }); + const runData: IRunData = { + [node1.name]: [toITaskData([{ data: { value: 1 } }])], + [node2.name]: [toITaskData([{ data: { value: 2 } }])], + [node3.name]: [toITaskData([{ data: { value: 3 } }])], + }; + + // ACT + const newRunData = cleanRunData(runData, graph, new Set([node2])); + + // ASSERT + expect(newRunData).toEqual({ + [node1.name]: [toITaskData([{ data: { value: 1 } }])], + }); + }); + + // ► + // ┌─────┐ ┌────────┐ + // │node1├─────►rootNode│ + // └─────┘ └───▲────┘ + // │ + // ┌───┴───┐ + // │subNode│ + // └───────┘ + test('removes run data of sub nodes when the start node is a root node', () => { + // ARRANGE + const node1 = createNodeData({ name: 'Node1' }); + const rootNode = createNodeData({ name: 'Root Node' }); + const subNode = createNodeData({ name: 'Sub Node' }); + const graph = new DirectedGraph() + .addNodes(node1, rootNode, subNode) + .addConnections( + { from: node1, to: rootNode }, + { from: subNode, to: rootNode, type: NodeConnectionType.AiLanguageModel }, + ); + const runData: IRunData = { + [node1.name]: [toITaskData([{ data: { value: 1 } }])], + [rootNode.name]: [toITaskData([{ data: { value: 2 } }])], + [subNode.name]: [toITaskData([{ data: { value: 3 } }])], + }; + + // ACT + const newRunData = cleanRunData(runData, graph, new Set([rootNode])); + + // ASSERT + expect(newRunData).toEqual({ + [node1.name]: [toITaskData([{ data: { value: 1 } }])], + }); + }); + + // ► + // ┌─────┐ ┌─────┐ ┌────────┐ + // │node1├───►node2├────►rootNode│ + // └─────┘ └─────┘ └───▲────┘ + // │ + // ┌───┴───┐ + // │subNode│ + // └───────┘ + test('removes run data of sub nodes for root nodes downstream of the start node', () => { + // ARRANGE + const node1 = createNodeData({ name: 'Node1' }); + const node2 = createNodeData({ name: 'Node2' }); + const rootNode = createNodeData({ name: 'Root Node' }); + const subNode = createNodeData({ name: 'Sub Node' }); + const graph = new DirectedGraph() + .addNodes(node1, node2, rootNode, subNode) + .addConnections( + { from: node1, to: node2 }, + { from: node2, to: rootNode }, + { from: subNode, to: rootNode, type: NodeConnectionType.AiLanguageModel }, + ); + const runData: IRunData = { + [node1.name]: [toITaskData([{ data: { value: 1 } }])], + [node2.name]: [toITaskData([{ data: { value: 1 } }])], + [rootNode.name]: [toITaskData([{ data: { value: 2 } }])], + [subNode.name]: [toITaskData([{ data: { value: 3 } }])], + }; + + // ACT + const newRunData = cleanRunData(runData, graph, new Set([node2])); + + // ASSERT + expect(newRunData).toEqual({ + [node1.name]: [toITaskData([{ data: { value: 1 } }])], + }); + }); + + // ► + // ┌─────┐ ┌────────┐ ┌────────┐ + // │node1├──►rootNode├──►rootNode│ + // └─────┘ └───▲────┘ └───▲────┘ + // │ │ + // │ ┌───┴───┐ + // └───────┤subNode│ + // └───────┘ + test('removes run data of sub nodes as well if the sub node is shared between multiple root nodes', () => { + // ARRANGE + const node1 = createNodeData({ name: 'Node1' }); + const rootNode1 = createNodeData({ name: 'Root Node 1' }); + const rootNode2 = createNodeData({ name: 'Root Node 2' }); + const subNode = createNodeData({ name: 'Sub Node' }); + const graph = new DirectedGraph() + .addNodes(node1, rootNode1, rootNode2, subNode) + .addConnections( + { from: node1, to: rootNode1 }, + { from: rootNode1, to: rootNode2 }, + { from: subNode, to: rootNode1, type: NodeConnectionType.AiLanguageModel }, + { from: subNode, to: rootNode2, type: NodeConnectionType.AiLanguageModel }, + ); + const runData: IRunData = { + [node1.name]: [toITaskData([{ data: { value: 1 } }])], + [rootNode1.name]: [toITaskData([{ data: { value: 1 } }])], + [rootNode2.name]: [toITaskData([{ data: { value: 2 } }])], + [subNode.name]: [toITaskData([{ data: { value: 3 } }])], + }; + + // ACT + const newRunData = cleanRunData(runData, graph, new Set([rootNode1])); + + // ASSERT + expect(newRunData).toEqual({ + [node1.name]: [toITaskData([{ data: { value: 1 } }])], + }); + }); +}); diff --git a/packages/core/src/PartialExecutionUtils/__tests__/DirectedGraph.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/directed-graph.test.ts similarity index 99% rename from packages/core/src/PartialExecutionUtils/__tests__/DirectedGraph.test.ts rename to packages/core/src/execution-engine/partial-execution-utils/__tests__/directed-graph.test.ts index 5d769004a5..a825c144a9 100644 --- a/packages/core/src/PartialExecutionUtils/__tests__/DirectedGraph.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/directed-graph.test.ts @@ -13,7 +13,7 @@ import type { INode } from 'n8n-workflow'; import { NodeConnectionType } from 'n8n-workflow'; import { createNodeData, defaultWorkflowParameter } from './helpers'; -import { DirectedGraph } from '../DirectedGraph'; +import { DirectedGraph } from '../directed-graph'; describe('DirectedGraph', () => { // ┌─────┐ ┌─────┐ ┌─────┐ diff --git a/packages/core/src/PartialExecutionUtils/__tests__/filterDisabledNodes.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/filter-disabled-nodes.test.ts similarity index 97% rename from packages/core/src/PartialExecutionUtils/__tests__/filterDisabledNodes.test.ts rename to packages/core/src/execution-engine/partial-execution-utils/__tests__/filter-disabled-nodes.test.ts index 7e60e009a8..69348720f7 100644 --- a/packages/core/src/PartialExecutionUtils/__tests__/filterDisabledNodes.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/filter-disabled-nodes.test.ts @@ -12,8 +12,8 @@ import { NodeConnectionType } from 'n8n-workflow'; import { createNodeData } from './helpers'; -import { DirectedGraph } from '../DirectedGraph'; -import { filterDisabledNodes } from '../filterDisabledNodes'; +import { DirectedGraph } from '../directed-graph'; +import { filterDisabledNodes } from '../filter-disabled-nodes'; describe('filterDisabledNodes', () => { // XX diff --git a/packages/core/src/PartialExecutionUtils/__tests__/findStartNodes.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-start-nodes.test.ts similarity index 99% rename from packages/core/src/PartialExecutionUtils/__tests__/findStartNodes.test.ts rename to packages/core/src/execution-engine/partial-execution-utils/__tests__/find-start-nodes.test.ts index f2a99fdb92..a0c86515c2 100644 --- a/packages/core/src/PartialExecutionUtils/__tests__/findStartNodes.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-start-nodes.test.ts @@ -12,8 +12,8 @@ import { type IPinData, type IRunData } from 'n8n-workflow'; import { createNodeData, toITaskData } from './helpers'; -import { DirectedGraph } from '../DirectedGraph'; -import { findStartNodes, isDirty } from '../findStartNodes'; +import { DirectedGraph } from '../directed-graph'; +import { findStartNodes, isDirty } from '../find-start-nodes'; describe('isDirty', () => { test("if the node has pinned data it's not dirty", () => { diff --git a/packages/core/src/PartialExecutionUtils/__tests__/findSubgraph.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-subgraph.test.ts similarity index 99% rename from packages/core/src/PartialExecutionUtils/__tests__/findSubgraph.test.ts rename to packages/core/src/execution-engine/partial-execution-utils/__tests__/find-subgraph.test.ts index e479214e10..1a6382017a 100644 --- a/packages/core/src/PartialExecutionUtils/__tests__/findSubgraph.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/find-subgraph.test.ts @@ -12,8 +12,8 @@ import { NodeConnectionType } from 'n8n-workflow'; import { createNodeData } from './helpers'; -import { DirectedGraph } from '../DirectedGraph'; -import { findSubgraph } from '../findSubgraph'; +import { DirectedGraph } from '../directed-graph'; +import { findSubgraph } from '../find-subgraph'; describe('findSubgraph', () => { // ►► diff --git a/packages/core/src/PartialExecutionUtils/__tests__/getSourceDataGroups.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/get-source-data-groups.test.ts similarity index 99% rename from packages/core/src/PartialExecutionUtils/__tests__/getSourceDataGroups.test.ts rename to packages/core/src/execution-engine/partial-execution-utils/__tests__/get-source-data-groups.test.ts index d8c3485d65..872a452aa7 100644 --- a/packages/core/src/PartialExecutionUtils/__tests__/getSourceDataGroups.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/get-source-data-groups.test.ts @@ -11,8 +11,8 @@ import type { IPinData } from 'n8n-workflow'; import { NodeConnectionType, type IRunData } from 'n8n-workflow'; import { createNodeData, toITaskData } from './helpers'; -import { DirectedGraph } from '../DirectedGraph'; -import { getSourceDataGroups } from '../getSourceDataGroups'; +import { DirectedGraph } from '../directed-graph'; +import { getSourceDataGroups } from '../get-source-data-groups'; describe('getSourceDataGroups', () => { //┌───────┐1 diff --git a/packages/core/src/PartialExecutionUtils/__tests__/handleCycles.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/handle-cycles.test.ts similarity index 98% rename from packages/core/src/PartialExecutionUtils/__tests__/handleCycles.test.ts rename to packages/core/src/execution-engine/partial-execution-utils/__tests__/handle-cycles.test.ts index def9fed0ff..06ed5daf47 100644 --- a/packages/core/src/PartialExecutionUtils/__tests__/handleCycles.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/handle-cycles.test.ts @@ -10,8 +10,8 @@ // PD denotes that the node has pinned data import { createNodeData } from './helpers'; -import { DirectedGraph } from '../DirectedGraph'; -import { handleCycles } from '../handleCycles'; +import { DirectedGraph } from '../directed-graph'; +import { handleCycles } from '../handle-cycles'; describe('handleCycles', () => { // ┌────┐ ┌─────────┐ diff --git a/packages/core/src/PartialExecutionUtils/__tests__/helpers.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/helpers.ts similarity index 100% rename from packages/core/src/PartialExecutionUtils/__tests__/helpers.ts rename to packages/core/src/execution-engine/partial-execution-utils/__tests__/helpers.ts diff --git a/packages/core/src/PartialExecutionUtils/__tests__/recreateNodeExecutionStack.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/recreate-node-execution-stack.test.ts similarity index 99% rename from packages/core/src/PartialExecutionUtils/__tests__/recreateNodeExecutionStack.test.ts rename to packages/core/src/execution-engine/partial-execution-utils/__tests__/recreate-node-execution-stack.test.ts index b78b9df135..0f20896e21 100644 --- a/packages/core/src/PartialExecutionUtils/__tests__/recreateNodeExecutionStack.test.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/__tests__/recreate-node-execution-stack.test.ts @@ -18,15 +18,14 @@ import type { } from 'n8n-workflow'; import { NodeConnectionType, type IPinData, type IRunData } from 'n8n-workflow'; +import { createNodeData, toITaskData } from './helpers'; +import { DirectedGraph } from '../directed-graph'; +import { findSubgraph } from '../find-subgraph'; import { addWaitingExecution, addWaitingExecutionSource, recreateNodeExecutionStack, -} from '@/PartialExecutionUtils/recreateNodeExecutionStack'; - -import { createNodeData, toITaskData } from './helpers'; -import { DirectedGraph } from '../DirectedGraph'; -import { findSubgraph } from '../findSubgraph'; +} from '../recreate-node-execution-stack'; describe('recreateNodeExecutionStack', () => { // ►► diff --git a/packages/core/src/PartialExecutionUtils/__tests__/toIConnections.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/to-iconnections.test.ts similarity index 100% rename from packages/core/src/PartialExecutionUtils/__tests__/toIConnections.test.ts rename to packages/core/src/execution-engine/partial-execution-utils/__tests__/to-iconnections.test.ts diff --git a/packages/core/src/PartialExecutionUtils/__tests__/toITaskData.test.ts b/packages/core/src/execution-engine/partial-execution-utils/__tests__/to-itask-data.test.ts similarity index 100% rename from packages/core/src/PartialExecutionUtils/__tests__/toITaskData.test.ts rename to packages/core/src/execution-engine/partial-execution-utils/__tests__/to-itask-data.test.ts diff --git a/packages/core/src/PartialExecutionUtils/cleanRunData.ts b/packages/core/src/execution-engine/partial-execution-utils/clean-run-data.ts similarity index 53% rename from packages/core/src/PartialExecutionUtils/cleanRunData.ts rename to packages/core/src/execution-engine/partial-execution-utils/clean-run-data.ts index 6ed5db6100..46f6eea52d 100644 --- a/packages/core/src/PartialExecutionUtils/cleanRunData.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/clean-run-data.ts @@ -1,6 +1,6 @@ -import type { INode, IRunData } from 'n8n-workflow'; +import { NodeConnectionType, type INode, type IRunData } from 'n8n-workflow'; -import type { DirectedGraph } from './DirectedGraph'; +import type { DirectedGraph } from './directed-graph'; /** * Returns new run data that does not contain data for any node that is a child @@ -16,10 +16,22 @@ export function cleanRunData( for (const startNode of startNodes) { delete newRunData[startNode.name]; - const children = graph.getChildren(startNode); - for (const child of children) { - delete newRunData[child.name]; + const children = graph.getChildren(startNode); + for (const node of [startNode, ...children]) { + delete newRunData[node.name]; + + // Delete runData for subNodes + const subNodeConnections = graph.getParentConnections(node); + for (const subNodeConnection of subNodeConnections) { + // Sub nodes never use the Main connection type, so this filters out + // the connection that goes upstream of the startNode. + if (subNodeConnection.type === NodeConnectionType.Main) { + continue; + } + + delete newRunData[subNodeConnection.from.name]; + } } } diff --git a/packages/core/src/PartialExecutionUtils/DirectedGraph.ts b/packages/core/src/execution-engine/partial-execution-utils/directed-graph.ts similarity index 100% rename from packages/core/src/PartialExecutionUtils/DirectedGraph.ts rename to packages/core/src/execution-engine/partial-execution-utils/directed-graph.ts diff --git a/packages/core/src/PartialExecutionUtils/filterDisabledNodes.ts b/packages/core/src/execution-engine/partial-execution-utils/filter-disabled-nodes.ts similarity index 88% rename from packages/core/src/PartialExecutionUtils/filterDisabledNodes.ts rename to packages/core/src/execution-engine/partial-execution-utils/filter-disabled-nodes.ts index c381f70759..af9ffb3512 100644 --- a/packages/core/src/PartialExecutionUtils/filterDisabledNodes.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/filter-disabled-nodes.ts @@ -1,6 +1,6 @@ import { NodeConnectionType } from 'n8n-workflow'; -import type { DirectedGraph } from './DirectedGraph'; +import type { DirectedGraph } from './directed-graph'; export function filterDisabledNodes(graph: DirectedGraph): DirectedGraph { const filteredGraph = graph.clone(); diff --git a/packages/core/src/PartialExecutionUtils/findStartNodes.ts b/packages/core/src/execution-engine/partial-execution-utils/find-start-nodes.ts similarity index 98% rename from packages/core/src/PartialExecutionUtils/findStartNodes.ts rename to packages/core/src/execution-engine/partial-execution-utils/find-start-nodes.ts index 1c1c0b9fc7..d7b3fb522d 100644 --- a/packages/core/src/PartialExecutionUtils/findStartNodes.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/find-start-nodes.ts @@ -1,7 +1,7 @@ import { NodeConnectionType, type INode, type IPinData, type IRunData } from 'n8n-workflow'; -import type { DirectedGraph } from './DirectedGraph'; -import { getIncomingData, getIncomingDataFromAnyRun } from './getIncomingData'; +import type { DirectedGraph } from './directed-graph'; +import { getIncomingData, getIncomingDataFromAnyRun } from './get-incoming-data'; /** * A node is dirty if either of the following is true: diff --git a/packages/core/src/PartialExecutionUtils/findSubgraph.ts b/packages/core/src/execution-engine/partial-execution-utils/find-subgraph.ts similarity index 97% rename from packages/core/src/PartialExecutionUtils/findSubgraph.ts rename to packages/core/src/execution-engine/partial-execution-utils/find-subgraph.ts index 039124c1ad..f333f4764e 100644 --- a/packages/core/src/PartialExecutionUtils/findSubgraph.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/find-subgraph.ts @@ -1,7 +1,7 @@ import { NodeConnectionType, type INode } from 'n8n-workflow'; -import type { GraphConnection } from './DirectedGraph'; -import { DirectedGraph } from './DirectedGraph'; +import type { GraphConnection } from './directed-graph'; +import { DirectedGraph } from './directed-graph'; function findSubgraphRecursive( graph: DirectedGraph, diff --git a/packages/core/src/PartialExecutionUtils/findTriggerForPartialExecution.ts b/packages/core/src/execution-engine/partial-execution-utils/find-trigger-for-partial-execution.ts similarity index 100% rename from packages/core/src/PartialExecutionUtils/findTriggerForPartialExecution.ts rename to packages/core/src/execution-engine/partial-execution-utils/find-trigger-for-partial-execution.ts diff --git a/packages/core/src/PartialExecutionUtils/getIncomingData.ts b/packages/core/src/execution-engine/partial-execution-utils/get-incoming-data.ts similarity index 100% rename from packages/core/src/PartialExecutionUtils/getIncomingData.ts rename to packages/core/src/execution-engine/partial-execution-utils/get-incoming-data.ts diff --git a/packages/core/src/PartialExecutionUtils/getSourceDataGroups.ts b/packages/core/src/execution-engine/partial-execution-utils/get-source-data-groups.ts similarity index 98% rename from packages/core/src/PartialExecutionUtils/getSourceDataGroups.ts rename to packages/core/src/execution-engine/partial-execution-utils/get-source-data-groups.ts index d9a9940816..d96aba3062 100644 --- a/packages/core/src/PartialExecutionUtils/getSourceDataGroups.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/get-source-data-groups.ts @@ -1,6 +1,6 @@ import { type INode, type IPinData, type IRunData } from 'n8n-workflow'; -import type { GraphConnection, DirectedGraph } from './DirectedGraph'; +import type { GraphConnection, DirectedGraph } from './directed-graph'; function sortByInputIndexThenByName( connection1: GraphConnection, diff --git a/packages/core/src/PartialExecutionUtils/handleCycles.ts b/packages/core/src/execution-engine/partial-execution-utils/handle-cycles.ts similarity index 96% rename from packages/core/src/PartialExecutionUtils/handleCycles.ts rename to packages/core/src/execution-engine/partial-execution-utils/handle-cycles.ts index 94a8ae8cbc..1e52b491ed 100644 --- a/packages/core/src/PartialExecutionUtils/handleCycles.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/handle-cycles.ts @@ -1,7 +1,7 @@ import type { INode } from 'n8n-workflow'; import * as a from 'node:assert/strict'; -import type { DirectedGraph } from './DirectedGraph'; +import type { DirectedGraph } from './directed-graph'; /** * Returns a new set of start nodes. diff --git a/packages/core/src/execution-engine/partial-execution-utils/index.ts b/packages/core/src/execution-engine/partial-execution-utils/index.ts new file mode 100644 index 0000000000..d363f52302 --- /dev/null +++ b/packages/core/src/execution-engine/partial-execution-utils/index.ts @@ -0,0 +1,8 @@ +export { DirectedGraph } from './directed-graph'; +export { findTriggerForPartialExecution } from './find-trigger-for-partial-execution'; +export { findStartNodes } from './find-start-nodes'; +export { findSubgraph } from './find-subgraph'; +export { recreateNodeExecutionStack } from './recreate-node-execution-stack'; +export { cleanRunData } from './clean-run-data'; +export { handleCycles } from './handle-cycles'; +export { filterDisabledNodes } from './filter-disabled-nodes'; diff --git a/packages/core/src/PartialExecutionUtils/recreateNodeExecutionStack.ts b/packages/core/src/execution-engine/partial-execution-utils/recreate-node-execution-stack.ts similarity index 97% rename from packages/core/src/PartialExecutionUtils/recreateNodeExecutionStack.ts rename to packages/core/src/execution-engine/partial-execution-utils/recreate-node-execution-stack.ts index 542d4b8fbd..95aced2515 100644 --- a/packages/core/src/PartialExecutionUtils/recreateNodeExecutionStack.ts +++ b/packages/core/src/execution-engine/partial-execution-utils/recreate-node-execution-stack.ts @@ -12,9 +12,9 @@ import { type IWaitingForExecutionSource, } from 'n8n-workflow'; -import type { DirectedGraph } from './DirectedGraph'; -import { getIncomingDataFromAnyRun } from './getIncomingData'; -import { getSourceDataGroups } from './getSourceDataGroups'; +import type { DirectedGraph } from './directed-graph'; +import { getIncomingDataFromAnyRun } from './get-incoming-data'; +import { getSourceDataGroups } from './get-source-data-groups'; export function addWaitingExecution( waitingExecution: IWaitingForExecution, diff --git a/packages/core/src/RoutingNode.ts b/packages/core/src/execution-engine/routing-node.ts similarity index 100% rename from packages/core/src/RoutingNode.ts rename to packages/core/src/execution-engine/routing-node.ts diff --git a/packages/core/src/ScheduledTaskManager.ts b/packages/core/src/execution-engine/scheduled-task-manager.ts similarity index 95% rename from packages/core/src/ScheduledTaskManager.ts rename to packages/core/src/execution-engine/scheduled-task-manager.ts index 0c33f9872c..1a20700b00 100644 --- a/packages/core/src/ScheduledTaskManager.ts +++ b/packages/core/src/execution-engine/scheduled-task-manager.ts @@ -2,7 +2,7 @@ import { Service } from '@n8n/di'; import { CronJob } from 'cron'; import type { CronExpression, Workflow } from 'n8n-workflow'; -import { InstanceSettings } from './InstanceSettings'; +import { InstanceSettings } from '@/instance-settings'; @Service() export class ScheduledTaskManager { diff --git a/packages/core/src/SSHClientsManager.ts b/packages/core/src/execution-engine/ssh-clients-manager.ts similarity index 100% rename from packages/core/src/SSHClientsManager.ts rename to packages/core/src/execution-engine/ssh-clients-manager.ts diff --git a/packages/core/src/TriggersAndPollers.ts b/packages/core/src/execution-engine/triggers-and-pollers.ts similarity index 100% rename from packages/core/src/TriggersAndPollers.ts rename to packages/core/src/execution-engine/triggers-and-pollers.ts diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/execution-engine/workflow-execute.ts similarity index 99% rename from packages/core/src/WorkflowExecute.ts rename to packages/core/src/execution-engine/workflow-execute.ts index ffd3b57f50..233b6c3c97 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/execution-engine/workflow-execute.ts @@ -44,7 +44,6 @@ import type { } from 'n8n-workflow'; import { LoggerProxy as Logger, - WorkflowOperationError, NodeHelpers, NodeConnectionType, ApplicationError, @@ -55,9 +54,11 @@ import { } from 'n8n-workflow'; import PCancelable from 'p-cancelable'; -import { ErrorReporter } from './error-reporter'; +import { ErrorReporter } from '@/errors/error-reporter'; +import { WorkflowHasIssuesError } from '@/errors/workflow-has-issues.error'; +import * as NodeExecuteFunctions from '@/node-execute-functions'; + import { ExecuteContext, PollContext } from './node-execution-context'; -import * as NodeExecuteFunctions from './NodeExecuteFunctions'; import { DirectedGraph, findStartNodes, @@ -67,9 +68,9 @@ import { recreateNodeExecutionStack, handleCycles, filterDisabledNodes, -} from './PartialExecutionUtils'; -import { RoutingNode } from './RoutingNode'; -import { TriggersAndPollers } from './TriggersAndPollers'; +} from './partial-execution-utils'; +import { RoutingNode } from './routing-node'; +import { TriggersAndPollers } from './triggers-and-pollers'; export class WorkflowExecute { private status: ExecutionStatus = 'new'; @@ -1245,9 +1246,7 @@ export class WorkflowExecute { pinDataNodeNames, }); if (workflowIssues !== null) { - throw new WorkflowOperationError( - 'The workflow has issues and cannot be executed for that reason. Please fix them first.', - ); + throw new WorkflowHasIssuesError(); } // Variables which hold temporary data for each node-execution diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7abbd9ad9a..0a620468bf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,29 +1,19 @@ -import * as NodeExecuteFunctions from './NodeExecuteFunctions'; +import * as NodeExecuteFunctions from './node-execute-functions'; -export * from './decorators'; -export * from './errors'; -export * from './ActiveWorkflows'; -export * from './BinaryData/BinaryData.service'; -export * from './BinaryData/types'; -export { Cipher } from './Cipher'; -export * from './Constants'; -export * from './Credentials'; -export * from './DirectoryLoader'; -export * from './Interfaces'; -export { InstanceSettings, InstanceType } from './InstanceSettings'; -export { Logger } from './logging/logger'; -export * from './NodeExecuteFunctions'; -export * from './RoutingNode'; -export * from './WorkflowExecute'; -export { NodeExecuteFunctions }; +export * from './binary-data'; +export * from './constants'; +export * from './credentials'; export * from './data-deduplication-service'; +export * from './decorators'; +export * from './encryption'; export * from './errors'; -export { ObjectStoreService } from './ObjectStore/ObjectStore.service.ee'; -export { BinaryData } from './BinaryData/types'; -export { isStoredMode as isValidNonDefaultMode } from './BinaryData/utils'; -export * from './ExecutionMetadata'; -export * from './node-execution-context'; -export * from './PartialExecutionUtils'; -export { ErrorReporter } from './error-reporter'; -export * from './SerializedBuffer'; -export { isObjectLiteral } from './utils'; +export * from './execution-engine'; +export * from './instance-settings'; +export * from './logging'; +export * from './nodes-loader'; +export * from './utils'; +export { WorkflowHasIssuesError } from './errors/workflow-has-issues.error'; + +export * from './interfaces'; +export * from './node-execute-functions'; +export { NodeExecuteFunctions }; diff --git a/packages/core/test/InstanceSettings.test.ts b/packages/core/src/instance-settings/__tests__/instance-settings.test.ts similarity index 81% rename from packages/core/test/InstanceSettings.test.ts rename to packages/core/src/instance-settings/__tests__/instance-settings.test.ts index 1fe96d3490..b57ed99f09 100644 --- a/packages/core/test/InstanceSettings.test.ts +++ b/packages/core/src/instance-settings/__tests__/instance-settings.test.ts @@ -2,11 +2,10 @@ import { mock } from 'jest-mock-extended'; jest.mock('node:fs', () => mock()); import * as fs from 'node:fs'; -import { InstanceSettings } from '@/InstanceSettings'; -import { InstanceSettingsConfig } from '@/InstanceSettingsConfig'; +import { InstanceSettings } from '@/instance-settings'; +import { InstanceSettingsConfig } from '@/instance-settings/instance-settings-config'; import { Logger } from '@/logging/logger'; - -import { mockInstance } from './utils'; +import { mockInstance } from '@test/utils'; describe('InstanceSettings', () => { const userFolder = '/test'; @@ -214,33 +213,58 @@ describe('InstanceSettings', () => { }); it('should return true if /.dockerenv exists', () => { - mockFs.existsSync.calledWith('/.dockerenv').mockReturnValueOnce(true); + mockFs.existsSync.mockImplementation((path) => path === '/.dockerenv'); expect(settings.isDocker).toBe(true); expect(mockFs.existsSync).toHaveBeenCalledWith('/.dockerenv'); expect(mockFs.readFileSync).not.toHaveBeenCalledWith('/proc/self/cgroup', 'utf8'); }); - it('should return true if /proc/self/cgroup contains docker', () => { - mockFs.existsSync.calledWith('/.dockerenv').mockReturnValueOnce(false); - mockFs.readFileSync - .calledWith('/proc/self/cgroup', 'utf8') - .mockReturnValueOnce('docker cgroup'); - + it('should return true if /run/.containerenv exists', () => { + mockFs.existsSync.mockImplementation((path) => path === '/run/.containerenv'); expect(settings.isDocker).toBe(true); - expect(mockFs.existsSync).toHaveBeenCalledWith('/.dockerenv'); - expect(mockFs.readFileSync).toHaveBeenCalledWith('/proc/self/cgroup', 'utf8'); + expect(mockFs.existsSync).toHaveBeenCalledWith('/run/.containerenv'); + expect(mockFs.readFileSync).not.toHaveBeenCalledWith('/proc/self/cgroup', 'utf8'); }); + test.each(['docker', 'kubepods', 'containerd'])( + 'should return true if /proc/self/cgroup contains %s', + (str) => { + mockFs.existsSync.mockReturnValueOnce(false); + mockFs.readFileSync.calledWith('/proc/self/cgroup', 'utf8').mockReturnValueOnce(str); + + expect(settings.isDocker).toBe(true); + expect(mockFs.existsSync).toHaveBeenCalledWith('/.dockerenv'); + expect(mockFs.readFileSync).toHaveBeenCalledWith('/proc/self/cgroup', 'utf8'); + }, + ); + + test.each(['docker', 'kubelet', 'containerd'])( + 'should return true if /proc/self/mountinfo contains %s', + (str) => { + mockFs.existsSync.mockReturnValueOnce(false); + mockFs.readFileSync.calledWith('/proc/self/cgroup', 'utf8').mockReturnValueOnce(''); + mockFs.readFileSync.calledWith('/proc/self/mountinfo', 'utf8').mockReturnValueOnce(str); + + expect(settings.isDocker).toBe(true); + expect(mockFs.existsSync).toHaveBeenCalledWith('/.dockerenv'); + expect(mockFs.readFileSync).toHaveBeenCalledWith('/proc/self/cgroup', 'utf8'); + expect(mockFs.readFileSync).toHaveBeenCalledWith('/proc/self/mountinfo', 'utf8'); + }, + ); + it('should return false if no docker indicators are found', () => { mockFs.existsSync.calledWith('/.dockerenv').mockReturnValueOnce(false); mockFs.readFileSync.calledWith('/proc/self/cgroup', 'utf8').mockReturnValueOnce(''); + mockFs.readFileSync.calledWith('/proc/self/mountinfo', 'utf8').mockReturnValueOnce(''); expect(settings.isDocker).toBe(false); }); - it('should return false if checking for docker throws an error', () => { - mockFs.existsSync.calledWith('/.dockerenv').mockImplementationOnce(() => { - throw new Error('Access denied'); + it('should return false if reading any of these files throws an error', () => { + mockFs.existsSync.mockReturnValue(false); + mockFs.readFileSync.mockImplementation(() => { + throw new Error('File not found'); }); + expect(settings.isDocker).toBe(false); }); diff --git a/packages/core/src/instance-settings/index.ts b/packages/core/src/instance-settings/index.ts new file mode 100644 index 0000000000..74b05f5d98 --- /dev/null +++ b/packages/core/src/instance-settings/index.ts @@ -0,0 +1 @@ +export { InstanceSettings, InstanceType } from './instance-settings'; diff --git a/packages/core/src/InstanceSettingsConfig.ts b/packages/core/src/instance-settings/instance-settings-config.ts similarity index 100% rename from packages/core/src/InstanceSettingsConfig.ts rename to packages/core/src/instance-settings/instance-settings-config.ts diff --git a/packages/core/src/InstanceSettings.ts b/packages/core/src/instance-settings/instance-settings.ts similarity index 92% rename from packages/core/src/InstanceSettings.ts rename to packages/core/src/instance-settings/instance-settings.ts index d06f68fc12..5f8a68596b 100644 --- a/packages/core/src/InstanceSettings.ts +++ b/packages/core/src/instance-settings/instance-settings.ts @@ -5,10 +5,10 @@ import { customAlphabet } from 'nanoid'; import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import path from 'path'; +import { Memoized } from '@/decorators'; import { Logger } from '@/logging/logger'; -import { Memoized } from './decorators'; -import { InstanceSettingsConfig } from './InstanceSettingsConfig'; +import { InstanceSettingsConfig } from './instance-settings-config'; const nanoid = customAlphabet(ALPHABET, 16); @@ -142,19 +142,30 @@ export class InstanceSettings { } /** - * Whether this instance is running inside a Docker container. - * - * Based on: https://github.com/sindresorhus/is-docker + * Whether this instance is running inside a Docker/Podman/Kubernetes container. */ @Memoized get isDocker() { + if (existsSync('/.dockerenv') || existsSync('/run/.containerenv')) return true; try { - return ( - existsSync('/.dockerenv') || readFileSync('/proc/self/cgroup', 'utf8').includes('docker') - ); - } catch { - return false; - } + const cgroupV1 = readFileSync('/proc/self/cgroup', 'utf8'); + if ( + cgroupV1.includes('docker') || + cgroupV1.includes('kubepods') || + cgroupV1.includes('containerd') + ) + return true; + } catch {} + try { + const cgroupV2 = readFileSync('/proc/self/mountinfo', 'utf8'); + if ( + cgroupV2.includes('docker') || + cgroupV2.includes('kubelet') || + cgroupV2.includes('containerd') + ) + return true; + } catch {} + return false; } update(newSettings: WritableSettings) { diff --git a/packages/core/src/Interfaces.ts b/packages/core/src/interfaces.ts similarity index 73% rename from packages/core/src/Interfaces.ts rename to packages/core/src/interfaces.ts index 2963e46185..a00176eee9 100644 --- a/packages/core/src/Interfaces.ts +++ b/packages/core/src/interfaces.ts @@ -20,19 +20,4 @@ export interface IWorkflowData { triggerResponses?: ITriggerResponse[]; } -export namespace n8n { - export interface PackageJson { - name: string; - version: string; - n8n?: { - credentials?: string[]; - nodes?: string[]; - }; - author?: { - name?: string; - email?: string; - }; - } -} - export type ExtendedValidationResult = ValidationResult & { fieldName?: string }; diff --git a/packages/core/src/logging/__tests__/logger.test.ts b/packages/core/src/logging/__tests__/logger.test.ts index d34eaf250a..1a9ee8e7ef 100644 --- a/packages/core/src/logging/__tests__/logger.test.ts +++ b/packages/core/src/logging/__tests__/logger.test.ts @@ -7,7 +7,7 @@ import type { GlobalConfig } from '@n8n/config'; import { mock } from 'jest-mock-extended'; import { LoggerProxy } from 'n8n-workflow'; -import type { InstanceSettingsConfig } from '@/InstanceSettingsConfig'; +import type { InstanceSettingsConfig } from '@/instance-settings/instance-settings-config'; import { Logger } from '../logger'; diff --git a/packages/core/src/logging/index.ts b/packages/core/src/logging/index.ts new file mode 100644 index 0000000000..a1332e91c6 --- /dev/null +++ b/packages/core/src/logging/index.ts @@ -0,0 +1 @@ +export { Logger } from './logger'; diff --git a/packages/core/src/logging/logger.ts b/packages/core/src/logging/logger.ts index 8115f93c86..82a47649ff 100644 --- a/packages/core/src/logging/logger.ts +++ b/packages/core/src/logging/logger.ts @@ -14,9 +14,9 @@ import path, { basename } from 'node:path'; import pc from 'picocolors'; import winston from 'winston'; -import { inDevelopment, inProduction } from '@/Constants'; -import { InstanceSettingsConfig } from '@/InstanceSettingsConfig'; -import { isObjectLiteral } from '@/utils'; +import { inDevelopment, inProduction } from '@/constants'; +import { InstanceSettingsConfig } from '@/instance-settings/instance-settings-config'; +import { isObjectLiteral } from '@/utils/is-object-literal'; const noOp = () => {}; diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/node-execute-functions.ts similarity index 99% rename from packages/core/src/NodeExecuteFunctions.ts rename to packages/core/src/node-execute-functions.ts index 9504ad1c78..fc94dc3434 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/node-execute-functions.ts @@ -98,9 +98,9 @@ import url, { URL, URLSearchParams } from 'url'; import { Logger } from '@/logging/logger'; -import { BinaryDataService } from './BinaryData/BinaryData.service'; -import type { BinaryData } from './BinaryData/types'; -import { binaryToBuffer } from './BinaryData/utils'; +import { BinaryDataService } from './binary-data/binary-data.service'; +import type { BinaryData } from './binary-data/types'; +import { binaryToBuffer } from './binary-data/utils'; import { BINARY_DATA_STORAGE_PATH, BLOCK_FILE_ACCESS_TO_N8N_FILES, @@ -109,14 +109,14 @@ import { RESTRICT_FILE_ACCESS_TO, UM_EMAIL_TEMPLATES_INVITE, UM_EMAIL_TEMPLATES_PWRESET, -} from './Constants'; +} from './constants'; import { DataDeduplicationService } from './data-deduplication-service'; -import { InstanceSettings } from './InstanceSettings'; -import type { IResponseError } from './Interfaces'; // eslint-disable-next-line import/no-cycle -import { PollContext, TriggerContext } from './node-execution-context'; -import { ScheduledTaskManager } from './ScheduledTaskManager'; -import { SSHClientsManager } from './SSHClientsManager'; +import { PollContext, TriggerContext } from './execution-engine/node-execution-context'; +import { ScheduledTaskManager } from './execution-engine/scheduled-task-manager'; +import { SSHClientsManager } from './execution-engine/ssh-clients-manager'; +import { InstanceSettings } from './instance-settings'; +import type { IResponseError } from './interfaces'; axios.defaults.timeout = 300000; // Prevent axios from adding x-form-www-urlencoded headers by default diff --git a/packages/core/src/node-execution-context/helpers/binary-helpers.ts b/packages/core/src/node-execution-context/helpers/binary-helpers.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/core/test/DirectoryLoader.test.ts b/packages/core/src/nodes-loader/__tests__/directory-loader.test.ts similarity index 98% rename from packages/core/test/DirectoryLoader.test.ts rename to packages/core/src/nodes-loader/__tests__/directory-loader.test.ts index 226b5a6fee..cb66300257 100644 --- a/packages/core/test/DirectoryLoader.test.ts +++ b/packages/core/src/nodes-loader/__tests__/directory-loader.test.ts @@ -22,12 +22,10 @@ jest.mock('fast-glob', () => async (pattern: string) => { : ['dist/Credential1.js']; }); -import * as classLoader from '@/ClassLoader'; -import { - CustomDirectoryLoader, - PackageDirectoryLoader, - LazyPackageDirectoryLoader, -} from '@/DirectoryLoader'; +import { CustomDirectoryLoader } from '../custom-directory-loader'; +import { LazyPackageDirectoryLoader } from '../lazy-package-directory-loader'; +import * as classLoader from '../load-class-in-isolation'; +import { PackageDirectoryLoader } from '../package-directory-loader'; describe('DirectoryLoader', () => { const directory = '/not/a/real/path'; diff --git a/packages/core/test/ClassLoader.test.ts b/packages/core/src/nodes-loader/__tests__/load-class-in-isolation.test.ts similarity index 93% rename from packages/core/test/ClassLoader.test.ts rename to packages/core/src/nodes-loader/__tests__/load-class-in-isolation.test.ts index 9527572662..656830fa87 100644 --- a/packages/core/test/ClassLoader.test.ts +++ b/packages/core/src/nodes-loader/__tests__/load-class-in-isolation.test.ts @@ -1,8 +1,8 @@ import vm from 'vm'; -import { loadClassInIsolation } from '@/ClassLoader'; +import { loadClassInIsolation } from '../load-class-in-isolation'; -describe('ClassLoader', () => { +describe('loadClassInIsolation', () => { const filePath = '/path/to/TestClass.js'; const className = 'TestClass'; diff --git a/packages/core/src/nodes-loader/constants.ts b/packages/core/src/nodes-loader/constants.ts new file mode 100644 index 0000000000..171be0352b --- /dev/null +++ b/packages/core/src/nodes-loader/constants.ts @@ -0,0 +1,31 @@ +import type { INodeProperties } from 'n8n-workflow'; +import { cronNodeOptions } from 'n8n-workflow'; + +export const CUSTOM_NODES_CATEGORY = 'Custom Nodes'; + +export const commonPollingParameters: INodeProperties[] = [ + { + displayName: 'Poll Times', + name: 'pollTimes', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + multipleValueButtonText: 'Add Poll Time', + }, + default: { item: [{ mode: 'everyMinute' }] }, + description: 'Time at which polling should occur', + placeholder: 'Add Poll Time', + options: cronNodeOptions, + }, +]; + +export const commonCORSParameters: INodeProperties[] = [ + { + displayName: 'Allowed Origins (CORS)', + name: 'allowedOrigins', + type: 'string', + default: '*', + description: + 'Comma-separated list of URLs allowed for cross-origin non-preflight requests. Use * (default) to allow all origins.', + }, +]; diff --git a/packages/core/src/nodes-loader/custom-directory-loader.ts b/packages/core/src/nodes-loader/custom-directory-loader.ts new file mode 100644 index 0000000000..8e84440608 --- /dev/null +++ b/packages/core/src/nodes-loader/custom-directory-loader.ts @@ -0,0 +1,31 @@ +import glob from 'fast-glob'; + +import { DirectoryLoader } from './directory-loader'; + +/** + * Loader for source files of nodes and credentials located in a custom dir, + * e.g. `~/.n8n/custom` + */ +export class CustomDirectoryLoader extends DirectoryLoader { + packageName = 'CUSTOM'; + + override async loadAll() { + const nodes = await glob('**/*.node.js', { + cwd: this.directory, + absolute: true, + }); + + for (const nodePath of nodes) { + this.loadNodeFromFile(nodePath); + } + + const credentials = await glob('**/*.credentials.js', { + cwd: this.directory, + absolute: true, + }); + + for (const credentialPath of credentials) { + this.loadCredentialFromFile(credentialPath); + } + } +} diff --git a/packages/core/src/DirectoryLoader.ts b/packages/core/src/nodes-loader/directory-loader.ts similarity index 66% rename from packages/core/src/DirectoryLoader.ts rename to packages/core/src/nodes-loader/directory-loader.ts index 559c0c5531..094aa8d1e0 100644 --- a/packages/core/src/DirectoryLoader.ts +++ b/packages/core/src/nodes-loader/directory-loader.ts @@ -1,5 +1,4 @@ import { Container } from '@n8n/di'; -import glob from 'fast-glob'; import uniqBy from 'lodash/uniqBy'; import type { CodexData, @@ -16,18 +15,15 @@ import type { IVersionedNodeType, KnownNodesAndCredentials, } from 'n8n-workflow'; -import { ApplicationError, applyDeclarativeNodeOptionParameters, jsonParse } from 'n8n-workflow'; -import { readFileSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; +import { ApplicationError, applyDeclarativeNodeOptionParameters } from 'n8n-workflow'; import * as path from 'path'; +import { UnrecognizedCredentialTypeError } from '@/errors/unrecognized-credential-type.error'; +import { UnrecognizedNodeTypeError } from '@/errors/unrecognized-node-type.error'; import { Logger } from '@/logging/logger'; -import { loadClassInIsolation } from './ClassLoader'; -import { commonCORSParameters, commonPollingParameters, CUSTOM_NODES_CATEGORY } from './Constants'; -import { UnrecognizedCredentialTypeError } from './errors/unrecognized-credential-type.error'; -import { UnrecognizedNodeTypeError } from './errors/unrecognized-node-type.error'; -import type { n8n } from './Interfaces'; +import { commonCORSParameters, commonPollingParameters, CUSTOM_NODES_CATEGORY } from './constants'; +import { loadClassInIsolation } from './load-class-in-isolation'; function toJSON(this: ICredentialType) { return { @@ -395,183 +391,3 @@ export abstract class DirectoryLoader { } } } - -/** - * Loader for source files of nodes and credentials located in a custom dir, - * e.g. `~/.n8n/custom` - */ -export class CustomDirectoryLoader extends DirectoryLoader { - packageName = 'CUSTOM'; - - override async loadAll() { - const nodes = await glob('**/*.node.js', { - cwd: this.directory, - absolute: true, - }); - - for (const nodePath of nodes) { - this.loadNodeFromFile(nodePath); - } - - const credentials = await glob('**/*.credentials.js', { - cwd: this.directory, - absolute: true, - }); - - for (const credentialPath of credentials) { - this.loadCredentialFromFile(credentialPath); - } - } -} - -/** - * Loader for source files of nodes and credentials located in a package dir, - * e.g. /nodes-base or community packages. - */ -export class PackageDirectoryLoader extends DirectoryLoader { - packageJson: n8n.PackageJson; - - packageName: string; - - constructor(directory: string, excludeNodes: string[] = [], includeNodes: string[] = []) { - super(directory, excludeNodes, includeNodes); - - this.packageJson = this.readJSONSync('package.json'); - this.packageName = this.packageJson.name; - this.excludeNodes = this.extractNodeTypes(excludeNodes); - this.includeNodes = this.extractNodeTypes(includeNodes); - } - - private extractNodeTypes(fullNodeTypes: string[]) { - return fullNodeTypes - .map((fullNodeType) => fullNodeType.split('.')) - .filter(([packageName]) => packageName === this.packageName) - .map(([_, nodeType]) => nodeType); - } - - override async loadAll() { - const { n8n } = this.packageJson; - if (!n8n) return; - - const { nodes, credentials } = n8n; - - if (Array.isArray(nodes)) { - for (const nodePath of nodes) { - this.loadNodeFromFile(nodePath); - } - } - - if (Array.isArray(credentials)) { - for (const credentialPath of credentials) { - this.loadCredentialFromFile(credentialPath); - } - } - - this.inferSupportedNodes(); - - this.logger.debug(`Loaded all credentials and nodes from ${this.packageName}`, { - credentials: credentials?.length ?? 0, - nodes: nodes?.length ?? 0, - }); - } - - private inferSupportedNodes() { - const knownCredentials = this.known.credentials; - for (const { type: credentialType } of Object.values(this.credentialTypes)) { - const supportedNodes = knownCredentials[credentialType.name].supportedNodes ?? []; - if (supportedNodes.length > 0 && credentialType.httpRequestNode) { - credentialType.httpRequestNode.hidden = true; - } - - credentialType.supportedNodes = supportedNodes; - - if (!credentialType.iconUrl && !credentialType.icon) { - for (const supportedNode of supportedNodes) { - const nodeDescription = this.nodeTypes[supportedNode]?.type.description; - - if (!nodeDescription) continue; - if (nodeDescription.icon) { - credentialType.icon = nodeDescription.icon; - credentialType.iconColor = nodeDescription.iconColor; - break; - } - if (nodeDescription.iconUrl) { - credentialType.iconUrl = nodeDescription.iconUrl; - break; - } - } - } - } - } - - private parseJSON(fileString: string, filePath: string): T { - try { - return jsonParse(fileString); - } catch (error) { - throw new ApplicationError('Failed to parse JSON', { extra: { filePath } }); - } - } - - protected readJSONSync(file: string): T { - const filePath = this.resolvePath(file); - const fileString = readFileSync(filePath, 'utf8'); - return this.parseJSON(fileString, filePath); - } - - protected async readJSON(file: string): Promise { - const filePath = this.resolvePath(file); - const fileString = await readFile(filePath, 'utf8'); - return this.parseJSON(fileString, filePath); - } -} - -/** - * This loader extends PackageDirectoryLoader to load node and credentials lazily, if possible - */ -export class LazyPackageDirectoryLoader extends PackageDirectoryLoader { - override async loadAll() { - try { - this.known.nodes = await this.readJSON('dist/known/nodes.json'); - this.known.credentials = await this.readJSON('dist/known/credentials.json'); - - this.types.nodes = await this.readJSON('dist/types/nodes.json'); - this.types.credentials = await this.readJSON('dist/types/credentials.json'); - - if (this.includeNodes.length) { - const allowedNodes: typeof this.known.nodes = {}; - for (const nodeType of this.includeNodes) { - if (nodeType in this.known.nodes) { - allowedNodes[nodeType] = this.known.nodes[nodeType]; - } - } - this.known.nodes = allowedNodes; - - this.types.nodes = this.types.nodes.filter((nodeType) => - this.includeNodes.includes(nodeType.name), - ); - } - - if (this.excludeNodes.length) { - for (const nodeType of this.excludeNodes) { - delete this.known.nodes[nodeType]; - } - - this.types.nodes = this.types.nodes.filter( - (nodeType) => !this.excludeNodes.includes(nodeType.name), - ); - } - - this.logger.debug(`Lazy-loading nodes and credentials from ${this.packageJson.name}`, { - nodes: this.types.nodes?.length ?? 0, - credentials: this.types.credentials?.length ?? 0, - }); - - this.isLazyLoaded = true; - - return; // We can load nodes and credentials lazily now - } catch { - this.logger.debug("Can't enable lazy-loading"); - await super.loadAll(); - } - } -} diff --git a/packages/core/src/nodes-loader/index.ts b/packages/core/src/nodes-loader/index.ts new file mode 100644 index 0000000000..f8fedca5be --- /dev/null +++ b/packages/core/src/nodes-loader/index.ts @@ -0,0 +1,5 @@ +export { DirectoryLoader, type Types } from './directory-loader'; +export { CustomDirectoryLoader } from './custom-directory-loader'; +export { PackageDirectoryLoader } from './package-directory-loader'; +export { LazyPackageDirectoryLoader } from './lazy-package-directory-loader'; +export type { n8n } from './types'; diff --git a/packages/core/src/nodes-loader/lazy-package-directory-loader.ts b/packages/core/src/nodes-loader/lazy-package-directory-loader.ts new file mode 100644 index 0000000000..5da6084e1a --- /dev/null +++ b/packages/core/src/nodes-loader/lazy-package-directory-loader.ts @@ -0,0 +1,52 @@ +import { PackageDirectoryLoader } from './package-directory-loader'; + +/** + * This loader extends PackageDirectoryLoader to load node and credentials lazily, if possible + */ +export class LazyPackageDirectoryLoader extends PackageDirectoryLoader { + override async loadAll() { + try { + this.known.nodes = await this.readJSON('dist/known/nodes.json'); + this.known.credentials = await this.readJSON('dist/known/credentials.json'); + + this.types.nodes = await this.readJSON('dist/types/nodes.json'); + this.types.credentials = await this.readJSON('dist/types/credentials.json'); + + if (this.includeNodes.length) { + const allowedNodes: typeof this.known.nodes = {}; + for (const nodeType of this.includeNodes) { + if (nodeType in this.known.nodes) { + allowedNodes[nodeType] = this.known.nodes[nodeType]; + } + } + this.known.nodes = allowedNodes; + + this.types.nodes = this.types.nodes.filter((nodeType) => + this.includeNodes.includes(nodeType.name), + ); + } + + if (this.excludeNodes.length) { + for (const nodeType of this.excludeNodes) { + delete this.known.nodes[nodeType]; + } + + this.types.nodes = this.types.nodes.filter( + (nodeType) => !this.excludeNodes.includes(nodeType.name), + ); + } + + this.logger.debug(`Lazy-loading nodes and credentials from ${this.packageJson.name}`, { + nodes: this.types.nodes?.length ?? 0, + credentials: this.types.credentials?.length ?? 0, + }); + + this.isLazyLoaded = true; + + return; // We can load nodes and credentials lazily now + } catch { + this.logger.debug("Can't enable lazy-loading"); + await super.loadAll(); + } + } +} diff --git a/packages/core/src/ClassLoader.ts b/packages/core/src/nodes-loader/load-class-in-isolation.ts similarity index 100% rename from packages/core/src/ClassLoader.ts rename to packages/core/src/nodes-loader/load-class-in-isolation.ts diff --git a/packages/core/src/nodes-loader/package-directory-loader.ts b/packages/core/src/nodes-loader/package-directory-loader.ts new file mode 100644 index 0000000000..4c46a684ec --- /dev/null +++ b/packages/core/src/nodes-loader/package-directory-loader.ts @@ -0,0 +1,107 @@ +import { ApplicationError, jsonParse } from 'n8n-workflow'; +import { readFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; + +import { DirectoryLoader } from './directory-loader'; +import type { n8n } from './types'; + +/** + * Loader for source files of nodes and credentials located in a package dir, + * e.g. /nodes-base or community packages. + */ +export class PackageDirectoryLoader extends DirectoryLoader { + packageJson: n8n.PackageJson; + + packageName: string; + + constructor(directory: string, excludeNodes: string[] = [], includeNodes: string[] = []) { + super(directory, excludeNodes, includeNodes); + + this.packageJson = this.readJSONSync('package.json'); + this.packageName = this.packageJson.name; + this.excludeNodes = this.extractNodeTypes(excludeNodes); + this.includeNodes = this.extractNodeTypes(includeNodes); + } + + private extractNodeTypes(fullNodeTypes: string[]) { + return fullNodeTypes + .map((fullNodeType) => fullNodeType.split('.')) + .filter(([packageName]) => packageName === this.packageName) + .map(([_, nodeType]) => nodeType); + } + + override async loadAll() { + const { n8n } = this.packageJson; + if (!n8n) return; + + const { nodes, credentials } = n8n; + + if (Array.isArray(nodes)) { + for (const nodePath of nodes) { + this.loadNodeFromFile(nodePath); + } + } + + if (Array.isArray(credentials)) { + for (const credentialPath of credentials) { + this.loadCredentialFromFile(credentialPath); + } + } + + this.inferSupportedNodes(); + + this.logger.debug(`Loaded all credentials and nodes from ${this.packageName}`, { + credentials: credentials?.length ?? 0, + nodes: nodes?.length ?? 0, + }); + } + + private inferSupportedNodes() { + const knownCredentials = this.known.credentials; + for (const { type: credentialType } of Object.values(this.credentialTypes)) { + const supportedNodes = knownCredentials[credentialType.name].supportedNodes ?? []; + if (supportedNodes.length > 0 && credentialType.httpRequestNode) { + credentialType.httpRequestNode.hidden = true; + } + + credentialType.supportedNodes = supportedNodes; + + if (!credentialType.iconUrl && !credentialType.icon) { + for (const supportedNode of supportedNodes) { + const nodeDescription = this.nodeTypes[supportedNode]?.type.description; + + if (!nodeDescription) continue; + if (nodeDescription.icon) { + credentialType.icon = nodeDescription.icon; + credentialType.iconColor = nodeDescription.iconColor; + break; + } + if (nodeDescription.iconUrl) { + credentialType.iconUrl = nodeDescription.iconUrl; + break; + } + } + } + } + } + + private parseJSON(fileString: string, filePath: string): T { + try { + return jsonParse(fileString); + } catch (error) { + throw new ApplicationError('Failed to parse JSON', { extra: { filePath } }); + } + } + + protected readJSONSync(file: string): T { + const filePath = this.resolvePath(file); + const fileString = readFileSync(filePath, 'utf8'); + return this.parseJSON(fileString, filePath); + } + + protected async readJSON(file: string): Promise { + const filePath = this.resolvePath(file); + const fileString = await readFile(filePath, 'utf8'); + return this.parseJSON(fileString, filePath); + } +} diff --git a/packages/core/src/nodes-loader/types.ts b/packages/core/src/nodes-loader/types.ts new file mode 100644 index 0000000000..e898c44b2d --- /dev/null +++ b/packages/core/src/nodes-loader/types.ts @@ -0,0 +1,14 @@ +export namespace n8n { + export interface PackageJson { + name: string; + version: string; + n8n?: { + credentials?: string[]; + nodes?: string[]; + }; + author?: { + name?: string; + email?: string; + }; + } +} diff --git a/packages/core/src/__tests__/utils.test.ts b/packages/core/src/utils/__tests__/is-object-literal.test.ts similarity index 95% rename from packages/core/src/__tests__/utils.test.ts rename to packages/core/src/utils/__tests__/is-object-literal.test.ts index a8532ed589..f6c3cfddd0 100644 --- a/packages/core/src/__tests__/utils.test.ts +++ b/packages/core/src/utils/__tests__/is-object-literal.test.ts @@ -1,4 +1,4 @@ -import { isObjectLiteral } from '@/utils'; +import { isObjectLiteral } from '../is-object-literal'; describe('isObjectLiteral', () => { test.each([ diff --git a/packages/core/test/SerializedBuffer.test.ts b/packages/core/src/utils/__tests__/serialized-buffer.test.ts similarity index 92% rename from packages/core/test/SerializedBuffer.test.ts rename to packages/core/src/utils/__tests__/serialized-buffer.test.ts index 95d7213401..19b7df496d 100644 --- a/packages/core/test/SerializedBuffer.test.ts +++ b/packages/core/src/utils/__tests__/serialized-buffer.test.ts @@ -1,5 +1,5 @@ -import type { SerializedBuffer } from '@/SerializedBuffer'; -import { toBuffer, isSerializedBuffer } from '@/SerializedBuffer'; +import type { SerializedBuffer } from '../serialized-buffer'; +import { toBuffer, isSerializedBuffer } from '../serialized-buffer'; // Mock data for tests const validSerializedBuffer: SerializedBuffer = { diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts new file mode 100644 index 0000000000..4973c13417 --- /dev/null +++ b/packages/core/src/utils/index.ts @@ -0,0 +1,2 @@ +export * from './serialized-buffer'; +export { isObjectLiteral } from './is-object-literal'; diff --git a/packages/core/src/utils.ts b/packages/core/src/utils/is-object-literal.ts similarity index 100% rename from packages/core/src/utils.ts rename to packages/core/src/utils/is-object-literal.ts diff --git a/packages/core/src/SerializedBuffer.ts b/packages/core/src/utils/serialized-buffer.ts similarity index 91% rename from packages/core/src/SerializedBuffer.ts rename to packages/core/src/utils/serialized-buffer.ts index d6ea874c7a..e98e1a0883 100644 --- a/packages/core/src/SerializedBuffer.ts +++ b/packages/core/src/utils/serialized-buffer.ts @@ -1,4 +1,4 @@ -import { isObjectLiteral } from '@/utils'; +import { isObjectLiteral } from './is-object-literal'; /** A nodejs Buffer gone through JSON.stringify */ export type SerializedBuffer = { diff --git a/packages/core/test/helpers/index.ts b/packages/core/test/helpers/index.ts index 14f19789b0..28cec2dff2 100644 --- a/packages/core/test/helpers/index.ts +++ b/packages/core/test/helpers/index.ts @@ -85,8 +85,7 @@ const preparePinData = (pinData: IDataObject) => { return returnData; }; -const readJsonFileSync = (filePath: string) => - JSON.parse(readFileSync(path.join(BASE_DIR, filePath), 'utf-8')) as T; +const readJsonFileSync = (filePath: string) => JSON.parse(readFileSync(filePath, 'utf-8')) as T; export function getNodeTypes(testData: WorkflowTestData[] | WorkflowTestData) { if (!Array.isArray(testData)) { @@ -100,7 +99,7 @@ export function getNodeTypes(testData: WorkflowTestData[] | WorkflowTestData) { const nodeNames = nodes.map((n) => n.type); const knownNodes = readJsonFileSync>( - 'nodes-base/dist/known/nodes.json', + path.join(BASE_DIR, 'nodes-base/dist/known/nodes.json'), ); for (const nodeName of nodeNames) { @@ -120,14 +119,14 @@ export function getNodeTypes(testData: WorkflowTestData[] | WorkflowTestData) { return nodeTypes; } -const getWorkflowFilenames = (dirname: string, testFolder = 'workflows') => { +const getWorkflowFilepaths = (dirname: string, testFolder = 'workflows') => { const workflows: string[] = []; const filenames: string[] = readdirSync(`${dirname}${path.sep}${testFolder}`); filenames.forEach((file) => { if (file.endsWith('.json')) { - workflows.push(path.join('core', 'test', testFolder, file)); + workflows.push(path.join(dirname, testFolder, file)); } }); @@ -135,11 +134,11 @@ const getWorkflowFilenames = (dirname: string, testFolder = 'workflows') => { }; export const workflowToTests = (dirname: string, testFolder = 'workflows') => { - const workflowFiles: string[] = getWorkflowFilenames(dirname, testFolder); + const workflowFilepaths: string[] = getWorkflowFilepaths(dirname, testFolder); const testCases: WorkflowTestData[] = []; - for (const filePath of workflowFiles) { + for (const filePath of workflowFilepaths) { const description = filePath.replace('.json', ''); const workflowData = readJsonFileSync(filePath); if (workflowData.pinData === undefined) { diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 111dd828dd..0fbcc8b81a 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -4,7 +4,8 @@ "rootDir": ".", "baseUrl": "src", "paths": { - "@/*": ["./*"] + "@/*": ["./*"], + "@test/*": ["../test/*"] }, "tsBuildInfoFile": "dist/typecheck.tsbuildinfo", "emitDecoratorMetadata": true, diff --git a/packages/design-system/package.json b/packages/design-system/package.json index d35d701825..a4c97de675 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,6 +1,6 @@ { "name": "n8n-design-system", - "version": "1.64.0", + "version": "1.66.0", "main": "src/main.ts", "import": "src/main.ts", "scripts": { @@ -20,8 +20,8 @@ }, "devDependencies": { "@n8n/storybook": "workspace:*", - "@testing-library/jest-dom": "^6.5.0", - "@testing-library/user-event": "^14.5.2", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/user-event": "^14.6.0", "@testing-library/vue": "^8.1.0", "@types/markdown-it": "^13.0.9", "@types/markdown-it-emoji": "^2.0.2", diff --git a/packages/design-system/src/components/N8nActionBox/ActionBox.vue b/packages/design-system/src/components/N8nActionBox/ActionBox.vue index 02de38143c..b04ccb1049 100644 --- a/packages/design-system/src/components/N8nActionBox/ActionBox.vue +++ b/packages/design-system/src/components/N8nActionBox/ActionBox.vue @@ -10,8 +10,8 @@ import N8nText from '../N8nText'; interface ActionBoxProps { emoji: string; heading: string; - buttonText: string; - buttonType: ButtonType; + buttonText?: string; + buttonType?: ButtonType; buttonDisabled?: boolean; buttonIcon?: string; description: string; diff --git a/packages/design-system/src/components/N8nDatatable/Datatable.vue b/packages/design-system/src/components/N8nDatatable/Datatable.vue index 905aef4671..d3447229d1 100644 --- a/packages/design-system/src/components/N8nDatatable/Datatable.vue +++ b/packages/design-system/src/components/N8nDatatable/Datatable.vue @@ -1,5 +1,5 @@ - diff --git a/packages/design-system/src/components/N8nSelectableList/__snapshots__/SelectableList.test.ts.snap b/packages/design-system/src/components/N8nSelectableList/__snapshots__/SelectableList.test.ts.snap new file mode 100644 index 0000000000..747dbeb778 --- /dev/null +++ b/packages/design-system/src/components/N8nSelectableList/__snapshots__/SelectableList.test.ts.snap @@ -0,0 +1,28 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`N8nSelectableList > renders disabled collection and clicks do not modify 1`] = ` +"
+
+ Add a propA+ Add a propC
+
+
+
+
" +`; + +exports[`N8nSelectableList > renders multiple elements with some pre-selected 1`] = ` +"
+
+ Add a propB+ Add a propD
+
+
+
+
+
+
+
" +`; + +exports[`N8nSelectableList > renders when empty 1`] = ` +"
+
+
" +`; diff --git a/packages/design-system/src/components/N8nSelectableList/index.ts b/packages/design-system/src/components/N8nSelectableList/index.ts new file mode 100644 index 0000000000..6c2d0e1127 --- /dev/null +++ b/packages/design-system/src/components/N8nSelectableList/index.ts @@ -0,0 +1,3 @@ +import N8nSelectableList from './SelectableList.vue'; + +export default N8nSelectableList; diff --git a/packages/design-system/src/components/index.ts b/packages/design-system/src/components/index.ts index ce4360fe46..6b584b8e8e 100644 --- a/packages/design-system/src/components/index.ts +++ b/packages/design-system/src/components/index.ts @@ -33,6 +33,7 @@ export { default as N8nNodeCreatorNode } from './N8nNodeCreatorNode'; export { default as N8nNodeIcon } from './N8nNodeIcon'; export { default as N8nNotice } from './N8nNotice'; export { default as N8nOption } from './N8nOption'; +export { default as N8nSelectableList } from './N8nSelectableList'; export { default as N8nPopover } from './N8nPopover'; export { default as N8nPulse } from './N8nPulse'; export { default as N8nRadioButtons } from './N8nRadioButtons'; diff --git a/packages/design-system/src/css/_tokens.dark.scss b/packages/design-system/src/css/_tokens.dark.scss index 00eeafb981..051de41601 100644 --- a/packages/design-system/src/css/_tokens.dark.scss +++ b/packages/design-system/src/css/_tokens.dark.scss @@ -227,6 +227,7 @@ --color-notification-background: var(--prim-gray-740); // Execution + --execution-card-background: var(--color-foreground-light); --execution-card-background-hover: var(--color-foreground-base); --execution-selector-background: var(--prim-gray-740); --execution-selector-text: var(--color-text-base); diff --git a/packages/design-system/src/css/_tokens.scss b/packages/design-system/src/css/_tokens.scss index 66ad82a4d7..50c98ac2ee 100644 --- a/packages/design-system/src/css/_tokens.scss +++ b/packages/design-system/src/css/_tokens.scss @@ -289,6 +289,7 @@ --execution-card-border-waiting: var(--prim-color-secondary-tint-300); --execution-card-border-running: var(--prim-color-alt-b-tint-250); --execution-card-border-unknown: var(--prim-gray-120); + --execution-card-background: var(--color-foreground-xlight); --execution-card-background-hover: var(--color-foreground-light); --execution-card-text-waiting: var(--color-secondary); --execution-selector-background: var(--color-background-dark); diff --git a/packages/design-system/src/locale/lang/en.ts b/packages/design-system/src/locale/lang/en.ts index 35f2fd9f0e..3743bf92c6 100644 --- a/packages/design-system/src/locale/lang/en.ts +++ b/packages/design-system/src/locale/lang/en.ts @@ -51,4 +51,5 @@ export default { 'iconPicker.button.defaultToolTip': 'Choose icon', 'iconPicker.tabs.icons': 'Icons', 'iconPicker.tabs.emojis': 'Emojis', + 'selectableList.addDefault': '+ Add a', } as N8nLocale; diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 97a7660293..83a84ded1e 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.74.0", + "version": "1.76.0", "description": "Workflow Editor UI for n8n", "main": "index.js", "scripts": { diff --git a/packages/editor-ui/src/__tests__/setup.ts b/packages/editor-ui/src/__tests__/setup.ts index 35bfe7aafb..3ddee75f14 100644 --- a/packages/editor-ui/src/__tests__/setup.ts +++ b/packages/editor-ui/src/__tests__/setup.ts @@ -61,3 +61,25 @@ Object.defineProperty(window, 'matchMedia', { dispatchEvent: vi.fn(), })), }); + +class Worker { + onmessage: (message: string) => void; + + url: string; + + constructor(url: string) { + this.url = url; + this.onmessage = () => {}; + } + + postMessage(message: string) { + this.onmessage(message); + } + + addEventListener() {} +} + +Object.defineProperty(window, 'Worker', { + writable: true, + value: Worker, +}); diff --git a/packages/editor-ui/src/api/ai.ts b/packages/editor-ui/src/api/ai.ts index 04c08c78f2..a6721d2652 100644 --- a/packages/editor-ui/src/api/ai.ts +++ b/packages/editor-ui/src/api/ai.ts @@ -1,6 +1,9 @@ +import { useAIAssistantHelpers } from '@/composables/useAIAssistantHelpers'; +import { AI_ASSISTANT_MAX_CONTENT_LENGTH } from '@/constants'; import type { ICredentialsResponse, IRestApiContext } from '@/Interface'; import type { AskAiRequest, ChatRequest, ReplaceCodeRequest } from '@/types/assistant.types'; import { makeRestApiRequest, streamRequest } from '@/utils/apiUtils'; +import { getObjectSizeInKB } from '@/utils/objectUtils'; import type { IDataObject } from 'n8n-workflow'; export function chatWithAssistant( @@ -10,6 +13,15 @@ export function chatWithAssistant( onDone: () => void, onError: (e: Error) => void, ): void { + try { + const payloadSize = getObjectSizeInKB(payload.payload); + if (payloadSize > AI_ASSISTANT_MAX_CONTENT_LENGTH) { + useAIAssistantHelpers().trimPayloadSize(payload); + } + } catch (e) { + onError(e); + return; + } void streamRequest( ctx, '/ai/chat', diff --git a/packages/editor-ui/src/api/testDefinition.ee.ts b/packages/editor-ui/src/api/testDefinition.ee.ts index 5f4ce66ef9..1cc39e3bd5 100644 --- a/packages/editor-ui/src/api/testDefinition.ee.ts +++ b/packages/editor-ui/src/api/testDefinition.ee.ts @@ -43,7 +43,7 @@ export interface UpdateTestResponse { export interface TestRunRecord { id: string; testDefinitionId: string; - status: 'new' | 'running' | 'completed' | 'error'; + status: 'new' | 'running' | 'completed' | 'error' | 'cancelled'; metrics?: Record; createdAt: string; updatedAt: string; @@ -221,6 +221,21 @@ export const startTestRun = async (context: IRestApiContext, testDefinitionId: s return response as { success: boolean }; }; +export const cancelTestRun = async ( + context: IRestApiContext, + testDefinitionId: string, + testRunId: string, +) => { + const response = await request({ + method: 'POST', + baseURL: context.baseUrl, + endpoint: `${endpoint}/${testDefinitionId}/runs/${testRunId}/cancel`, + headers: { 'push-ref': context.pushRef }, + }); + // CLI is returning the response without wrapping it in `data` key + return response as { success: boolean }; +}; + // Delete a test run export const deleteTestRun = async (context: IRestApiContext, params: DeleteTestRunParams) => { return await makeRestApiRequest<{ success: boolean }>( diff --git a/packages/editor-ui/src/components/ButtonParameter/utils.ts b/packages/editor-ui/src/components/ButtonParameter/utils.ts index 1044477bbb..47a4081963 100644 --- a/packages/editor-ui/src/components/ButtonParameter/utils.ts +++ b/packages/editor-ui/src/components/ButtonParameter/utils.ts @@ -161,28 +161,24 @@ export function reducePayloadSizeOrThrow( error: Error, averageTokenLength = 4, ) { - try { - let remainingTokensToReduce = calculateRemainingTokens(error); + let remainingTokensToReduce = calculateRemainingTokens(error); - const [remaining, parentNodesTokenCount] = trimParentNodesSchema( - payload, - remainingTokensToReduce, - averageTokenLength, - ); + const [remaining, parentNodesTokenCount] = trimParentNodesSchema( + payload, + remainingTokensToReduce, + averageTokenLength, + ); - remainingTokensToReduce = remaining; + remainingTokensToReduce = remaining; - remainingTokensToReduce = trimInputSchemaProperties( - payload, - remainingTokensToReduce, - averageTokenLength, - parentNodesTokenCount, - ); + remainingTokensToReduce = trimInputSchemaProperties( + payload, + remainingTokensToReduce, + averageTokenLength, + parentNodesTokenCount, + ); - if (remainingTokensToReduce > 0) throw error; - } catch (e) { - throw e; - } + if (remainingTokensToReduce > 0) throw error; } export async function generateCodeForAiTransform(prompt: string, path: string, retries = 1) { diff --git a/packages/editor-ui/src/components/CanvasChat/CanvasChat.test.ts b/packages/editor-ui/src/components/CanvasChat/CanvasChat.test.ts index 47869583a4..1172768ace 100644 --- a/packages/editor-ui/src/components/CanvasChat/CanvasChat.test.ts +++ b/packages/editor-ui/src/components/CanvasChat/CanvasChat.test.ts @@ -230,28 +230,28 @@ describe('CanvasChat', () => { // Verify workflow execution expect(workflowsStore.runWorkflow).toHaveBeenCalledWith( expect.objectContaining({ - runData: { - 'When chat message received': [ - { - data: { - main: [ - [ - { - json: { - action: 'sendMessage', - chatInput: 'Hello AI!', - sessionId: expect.any(String), - }, + runData: undefined, + triggerToStartFrom: { + name: 'When chat message received', + data: { + data: { + main: [ + [ + { + json: { + action: 'sendMessage', + chatInput: 'Hello AI!', + sessionId: expect.any(String), }, - ], + }, ], - }, - executionStatus: 'success', - executionTime: 0, - source: [null], - startTime: expect.any(Number), + ], }, - ], + executionStatus: 'success', + executionTime: 0, + source: [null], + startTime: expect.any(Number), + }, }, }), ); diff --git a/packages/editor-ui/src/components/CanvasChat/CanvasChat.vue b/packages/editor-ui/src/components/CanvasChat/CanvasChat.vue index adfbc8cdd4..b2212ffda8 100644 --- a/packages/editor-ui/src/components/CanvasChat/CanvasChat.vue +++ b/packages/editor-ui/src/components/CanvasChat/CanvasChat.vue @@ -200,22 +200,25 @@ async function createExecutionPromise() { } async function onRunChatWorkflow(payload: RunWorkflowChatPayload) { - try { - const response = await runWorkflow({ - triggerNode: payload.triggerNode, - nodeData: payload.nodeData, - source: payload.source, - }); + const runWorkflowOptions: Parameters[0] = { + triggerNode: payload.triggerNode, + nodeData: payload.nodeData, + source: payload.source, + }; - if (response) { - await createExecutionPromise(); - workflowsStore.appendChatMessage(payload.message); - return response; - } - return; - } catch (error) { - throw error; + if (workflowsStore.chatPartialExecutionDestinationNode) { + runWorkflowOptions.destinationNode = workflowsStore.chatPartialExecutionDestinationNode; + workflowsStore.chatPartialExecutionDestinationNode = null; } + + const response = await runWorkflow(runWorkflowOptions); + + if (response) { + await createExecutionPromise(); + workflowsStore.appendChatMessage(payload.message); + return response; + } + return; } // Initialize chat config diff --git a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue index c17ff804c0..74bbddacc3 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue +++ b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue @@ -37,7 +37,7 @@ const props = withDefaults(defineProps(), { language: 'javaScript', isReadOnly: false, rows: 4, - id: crypto.randomUUID(), + id: () => crypto.randomUUID(), }); const emit = defineEmits<{ 'update:modelValue': [value: string]; diff --git a/packages/editor-ui/src/components/ContextMenu/ContextMenu.vue b/packages/editor-ui/src/components/ContextMenu/ContextMenu.vue index 0fd54b0a73..2425559e83 100644 --- a/packages/editor-ui/src/components/ContextMenu/ContextMenu.vue +++ b/packages/editor-ui/src/components/ContextMenu/ContextMenu.vue @@ -29,7 +29,9 @@ function onActionSelect(item: string) { } function closeMenu(event: MouseEvent) { - event.preventDefault(); + if (event.cancelable) { + event.preventDefault(); + } event.stopPropagation(); contextMenu.close(); } diff --git a/packages/editor-ui/src/components/ExternalSecretsProviderImage.ee.vue b/packages/editor-ui/src/components/ExternalSecretsProviderImage.ee.vue index 8d8eec9109..8763c7aca2 100644 --- a/packages/editor-ui/src/components/ExternalSecretsProviderImage.ee.vue +++ b/packages/editor-ui/src/components/ExternalSecretsProviderImage.ee.vue @@ -5,27 +5,20 @@ import { computed } from 'vue'; import infisical from '../assets/images/infisical.webp'; import doppler from '../assets/images/doppler.webp'; import vault from '../assets/images/hashicorp.webp'; -import awsSecretsManager from '../assets/images/aws-secrets-manager.svg'; -import azureKeyVault from '../assets/images/azure-key-vault.svg'; -import gcpSecretsManager from '../assets/images/gcp-secrets-manager.svg'; +import AwsSecretsManager from '../assets/images/aws-secrets-manager.svg'; +import AzureKeyVault from '../assets/images/azure-key-vault.svg'; +import GcpSecretsManager from '../assets/images/gcp-secrets-manager.svg'; -const props = defineProps<{ +const { provider } = defineProps<{ provider: ExternalSecretsProvider; }>(); -const image = computed( - () => - ({ - doppler, - infisical, - vault, - awsSecretsManager, - azureKeyVault, - gcpSecretsManager, - })[props.provider.name], -); +const image = computed(() => ({ doppler, infisical, vault })[provider.name]); diff --git a/packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue b/packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue index 91b9b0c35a..c7202ef498 100644 --- a/packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue +++ b/packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue @@ -83,6 +83,7 @@ const { editor: editorRef, segments, readEditorValue, + isDirty, } = useExpressionEditor({ editorRef: htmlEditor, editorValue, @@ -230,6 +231,7 @@ onMounted(() => { }); onBeforeUnmount(() => { + if (isDirty.value) emit('update:model-value', readEditorValue()); htmlEditorEventBus.off('format-html', formatHtml); }); @@ -246,7 +248,10 @@ async function onDrop(value: string, event: MouseEvent) { @@ -264,6 +269,10 @@ async function onDrop(value: string, event: MouseEvent) { } } +.fillHeight { + height: 100%; +} + .droppable { :global(.cm-editor) { border-color: var(--color-ndv-droppable-parameter); diff --git a/packages/editor-ui/src/components/InputPanel.test.ts b/packages/editor-ui/src/components/InputPanel.test.ts index 0b4734068a..a6c27abe8b 100644 --- a/packages/editor-ui/src/components/InputPanel.test.ts +++ b/packages/editor-ui/src/components/InputPanel.test.ts @@ -4,10 +4,15 @@ import InputPanel, { type Props } from '@/components/InputPanel.vue'; import { STORES } from '@/constants'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { createTestingPinia } from '@pinia/testing'; -import { NodeConnectionType, type IConnections, type INodeExecutionData } from 'n8n-workflow'; +import { waitFor } from '@testing-library/vue'; +import { + NodeConnectionType, + type IConnections, + type INodeExecutionData, + type IRunData, +} from 'n8n-workflow'; import { setActivePinia } from 'pinia'; import { mockedStore } from '../__tests__/utils'; -import { waitFor } from '@testing-library/vue'; vi.mock('vue-router', () => { return { @@ -24,7 +29,7 @@ const nodes = [ createTestNode({ name: 'Tool' }), ]; -const render = (props: Partial = {}, pinData?: INodeExecutionData[]) => { +const render = (props: Partial = {}, pinData?: INodeExecutionData[], runData?: IRunData) => { const connections: IConnections = { [nodes[0].name]: { [NodeConnectionType.Main]: [ @@ -50,12 +55,38 @@ const render = (props: Partial = {}, pinData?: INodeExecutionData[]) => { setActivePinia(pinia); const workflow = createTestWorkflow({ nodes, connections }); - useWorkflowsStore().setWorkflow(workflow); + const workflowStore = useWorkflowsStore(); + + workflowStore.setWorkflow(workflow); if (pinData) { mockedStore(useWorkflowsStore).pinDataByNodeName.mockReturnValue(pinData); } + if (runData) { + workflowStore.setWorkflowExecutionData({ + id: '', + workflowData: { + id: '', + name: '', + active: false, + createdAt: '', + updatedAt: '', + nodes, + connections, + versionId: '', + }, + finished: false, + mode: 'trigger', + status: 'success', + startedAt: new Date(), + createdAt: new Date(), + data: { + resultData: { runData }, + }, + }); + } + const workflowObject = createTestWorkflowObject({ nodes, connections, @@ -83,4 +114,27 @@ describe('InputPanel', () => { await waitFor(() => expect(queryByTestId('ndv-data-size-warning')).not.toBeInTheDocument()); expect(container).toMatchSnapshot(); }); + + it("opens mapping tab by default if the node hasn't run yet", async () => { + const { findByTestId } = render({ currentNodeName: 'Tool' }); + + expect((await findByTestId('radio-button-mapping')).parentNode).toBeChecked(); + expect((await findByTestId('radio-button-debugging')).parentNode).not.toBeChecked(); + }); + + it('opens debugging tab by default if the node has already run', async () => { + const { findByTestId } = render({ currentNodeName: 'Tool' }, undefined, { + Tool: [ + { + startTime: 0, + executionTime: 0, + source: [], + data: {}, + }, + ], + }); + + expect((await findByTestId('radio-button-mapping')).parentNode).not.toBeChecked(); + expect((await findByTestId('radio-button-debugging')).parentNode).toBeChecked(); + }); }); diff --git a/packages/editor-ui/src/components/InputPanel.vue b/packages/editor-ui/src/components/InputPanel.vue index 2dab3ea97b..41b85e3389 100644 --- a/packages/editor-ui/src/components/InputPanel.vue +++ b/packages/editor-ui/src/components/InputPanel.vue @@ -1,27 +1,27 @@