Merge branch 'master' into node-1929-summarize-node-throws-an-error-that-should-be-a-warning

This commit is contained in:
Elias Meire 2025-01-24 14:49:55 +01:00
commit 6f656d3b6b
No known key found for this signature in database
502 changed files with 13838 additions and 6884 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -2,6 +2,8 @@
* Getters
*/
import { clearNotifications } from '../../pages/notifications';
export function getCredentialConnectionParameterInputs() {
return cy.getByTestId('credential-connection-parameter');
}
@ -55,5 +57,6 @@ export function setCredentialValues(values: Record<string, string>, save = true)
if (save) {
saveCredential();
closeCredentialModal();
clearNotifications();
}
}

View file

@ -0,0 +1,81 @@
import { BACKEND_BASE_URL } from '../constants';
import { NDV, WorkflowPage } from '../pages';
import { getVisibleSelect } from '../utils';
export const waitForWebhook = 500;
export interface SimpleWebhookCallOptions {
method: string;
webhookPath: string;
responseCode?: number;
respondWith?: string;
executeNow?: boolean;
responseData?: string;
authentication?: string;
}
const workflowPage = new WorkflowPage();
const ndv = new NDV();
export const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
const {
authentication,
method,
webhookPath,
responseCode,
respondWith,
responseData,
executeNow = true,
} = options;
workflowPage.actions.addInitialNodeToCanvas('Webhook');
workflowPage.actions.openNode('Webhook');
cy.getByTestId('parameter-input-httpMethod').click();
getVisibleSelect().find('.option-headline').contains(method).click();
cy.getByTestId('parameter-input-path')
.find('.parameter-input')
.find('input')
.clear()
.type(webhookPath);
if (authentication) {
cy.getByTestId('parameter-input-authentication').click();
getVisibleSelect().find('.option-headline').contains(authentication).click();
}
if (responseCode) {
cy.get('.param-options').click();
getVisibleSelect().contains('Response Code').click();
cy.get('.parameter-item-wrapper > .parameter-input-list-wrapper').children().click();
getVisibleSelect().contains('201').click();
}
if (respondWith) {
cy.getByTestId('parameter-input-responseMode').click();
getVisibleSelect().find('.option-headline').contains(respondWith).click();
}
if (responseData) {
cy.getByTestId('parameter-input-responseData').click();
getVisibleSelect().find('.option-headline').contains(responseData).click();
}
const callEndpoint = (fn: (response: Cypress.Response<unknown>) => void) => {
cy.request(method, `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then(fn);
};
if (executeNow) {
ndv.actions.execute();
cy.wait(waitForWebhook);
callEndpoint((response) => {
expect(response.status).to.eq(200);
ndv.getters.outputPanel().contains('headers');
});
}
return {
callEndpoint,
};
};

View file

@ -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();
}

View file

@ -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));

View file

@ -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();

View file

@ -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]);
}
});
});

View file

@ -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');
});
});

View file

@ -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();

View file

@ -1,5 +1,6 @@
import { nanoid } from 'nanoid';
import { simpleWebhookCall, waitForWebhook } from '../composables/webhooks';
import { BACKEND_BASE_URL, EDIT_FIELDS_SET_NODE_NAME } from '../constants';
import { WorkflowPage, NDV, CredentialsModal } from '../pages';
import { cowBase64 } from '../support/binaryTestFiles';
@ -9,81 +10,6 @@ const workflowPage = new WorkflowPage();
const ndv = new NDV();
const credentialsModal = new CredentialsModal();
export const waitForWebhook = 500;
interface SimpleWebhookCallOptions {
method: string;
webhookPath: string;
responseCode?: number;
respondWith?: string;
executeNow?: boolean;
responseData?: string;
authentication?: string;
}
export const simpleWebhookCall = (options: SimpleWebhookCallOptions) => {
const {
authentication,
method,
webhookPath,
responseCode,
respondWith,
responseData,
executeNow = true,
} = options;
workflowPage.actions.addInitialNodeToCanvas('Webhook');
workflowPage.actions.openNode('Webhook');
cy.getByTestId('parameter-input-httpMethod').click();
getVisibleSelect().find('.option-headline').contains(method).click();
cy.getByTestId('parameter-input-path')
.find('.parameter-input')
.find('input')
.clear()
.type(webhookPath);
if (authentication) {
cy.getByTestId('parameter-input-authentication').click();
getVisibleSelect().find('.option-headline').contains(authentication).click();
}
if (responseCode) {
cy.get('.param-options').click();
getVisibleSelect().contains('Response Code').click();
cy.get('.parameter-item-wrapper > .parameter-input-list-wrapper').children().click();
getVisibleSelect().contains('201').click();
}
if (respondWith) {
cy.getByTestId('parameter-input-responseMode').click();
getVisibleSelect().find('.option-headline').contains(respondWith).click();
}
if (responseData) {
cy.getByTestId('parameter-input-responseData').click();
getVisibleSelect().find('.option-headline').contains(responseData).click();
}
const callEndpoint = (cb: (response: Cypress.Response<unknown>) => void) => {
cy.request(method, `${BACKEND_BASE_URL}/webhook-test/${webhookPath}`).then(cb);
};
if (executeNow) {
ndv.actions.execute();
cy.wait(waitForWebhook);
callEndpoint((response) => {
expect(response.status).to.eq(200);
ndv.getters.outputPanel().contains('headers');
});
}
return {
callEndpoint,
};
};
describe('Webhook Trigger node', () => {
beforeEach(() => {
workflowPage.actions.visit();

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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');
});
});

View file

@ -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]',
);
});
});

View file

@ -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);

View file

@ -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);

View file

@ -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', 'Im 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);
}

View file

@ -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');

View file

@ -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');
});
});

View file

@ -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', () => {

View file

@ -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);
});
});
});
});

View file

@ -1,6 +1,10 @@
import { setCredentialValues } from '../composables/modals/credential-modal';
import { clickCreateNewCredential } from '../composables/ndv';
import { MANUAL_TRIGGER_NODE_DISPLAY_NAME, NOTION_NODE_NAME } from '../constants';
import { clickCreateNewCredential, setParameterSelectByContent } from '../composables/ndv';
import {
EDIT_FIELDS_SET_NODE_NAME,
MANUAL_TRIGGER_NODE_DISPLAY_NAME,
NOTION_NODE_NAME,
} from '../constants';
import { NDV, WorkflowPage } from '../pages';
import { NodeCreator } from '../pages/features/node-creator';
@ -106,7 +110,10 @@ describe('NDV', () => {
ndv.actions.execute();
ndv.getters
.nodeRunErrorMessage()
.should('have.text', 'Info for expression missing from previous node');
.should(
'have.text',
"Using the item method doesn't work with pinned data in this scenario. Please unpin 'Break pairedItem chain' and try again.",
);
ndv.getters
.nodeRunErrorDescription()
.should(
@ -242,6 +249,15 @@ describe('NDV', () => {
ndv.actions.switchInputMode('Table');
ndv.actions.switchOutputMode('Table');
// Start from linked state
ndv.getters.outputLinkRun().then(($el) => {
const classList = Array.from($el[0].classList);
if (!classList.includes('linked')) {
ndv.actions.toggleOutputRunLinking();
ndv.getters.inputTbodyCell(1, 0).click(); // remove tooltip
}
});
ndv.getters
.inputRunSelector()
.should('exist')
@ -359,15 +375,71 @@ describe('NDV', () => {
ndv.getters.nodeExecuteButton().should('be.visible');
});
it('should allow editing code in fullscreen in the Code node', () => {
it('should allow editing code in fullscreen in the code editors', () => {
// Code (JavaScript)
workflowPage.actions.addInitialNodeToCanvas('Code', { keepNdvOpen: true });
ndv.actions.openCodeEditorFullscreen();
ndv.getters.codeEditorFullscreen().type('{selectall}').type('{backspace}').type('foo()');
ndv.getters.codeEditorFullscreen().should('contain.text', 'foo()');
cy.wait(200);
cy.wait(200); // allow change to emit before closing modal
ndv.getters.codeEditorDialog().find('.el-dialog__close').click();
ndv.getters.parameterInput('jsCode').get('.cm-content').should('contain.text', 'foo()');
ndv.actions.close();
// SQL
workflowPage.actions.addNodeToCanvas('Postgres', true, true, 'Execute a SQL query');
ndv.actions.openCodeEditorFullscreen();
ndv.getters
.codeEditorFullscreen()
.type('{selectall}')
.type('{backspace}')
.type('SELECT * FROM workflows');
ndv.getters.codeEditorFullscreen().should('contain.text', 'SELECT * FROM workflows');
cy.wait(200);
ndv.getters.codeEditorDialog().find('.el-dialog__close').click();
ndv.getters
.parameterInput('query')
.get('.cm-content')
.should('contain.text', 'SELECT * FROM workflows');
ndv.actions.close();
// HTML
workflowPage.actions.addNodeToCanvas('HTML', true, true, 'Generate HTML template');
ndv.actions.openCodeEditorFullscreen();
ndv.getters
.codeEditorFullscreen()
.type('{selectall}')
.type('{backspace}')
.type('<div>Hello World');
ndv.getters.codeEditorFullscreen().should('contain.text', '<div>Hello World</div>');
cy.wait(200);
ndv.getters.codeEditorDialog().find('.el-dialog__close').click();
ndv.getters
.parameterInput('html')
.get('.cm-content')
.should('contain.text', '<div>Hello World</div>');
ndv.actions.close();
// JSON
workflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME, true, true);
setParameterSelectByContent('mode', 'JSON');
ndv.actions.openCodeEditorFullscreen();
ndv.getters
.codeEditorFullscreen()
.type('{selectall}')
.type('{backspace}')
.type('{ "key": "value" }', { parseSpecialCharSequences: false });
ndv.getters.codeEditorFullscreen().should('contain.text', '{ "key": "value" }');
cy.wait(200);
ndv.getters.codeEditorDialog().find('.el-dialog__close').click();
ndv.getters
.parameterInput('jsonOutput')
.get('.cm-content')
.should('contain.text', '{ "key": "value" }');
});
it('should not retrieve remote options when a parameter value changes', () => {

View file

@ -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');
},
);
});
});
});

View file

@ -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();

View file

@ -0,0 +1,77 @@
{
"nodes": [
{
"parameters": {
"options": {}
},
"id": "535fd3dd-e78f-4ffa-a085-79723fc81b38",
"name": "When chat message received",
"type": "@n8n/n8n-nodes-langchain.chatTrigger",
"typeVersion": 1.1,
"position": [
320,
-380
],
"webhookId": "4fb58136-3481-494a-a30f-d9e064dac186"
},
{
"parameters": {
"mode": "raw",
"jsonOutput": "{\n \"this_my_field_1\": \"value\",\n \"this_my_field_2\": 1\n}\n",
"options": {}
},
"id": "78201ec2-6def-40b7-85e5-97b580d7f642",
"name": "Node 1",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
580,
-380
]
},
{
"parameters": {
"mode": "raw",
"jsonOutput": "{\n \"this_my_field_3\": \"value\",\n \"this_my_field_4\": 1\n}\n",
"options": {}
},
"id": "1cfca06d-3ec3-427f-89f7-1ef321e025ff",
"name": "Node 2",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
780,
-380
]
}
],
"connections": {
"When chat message received": {
"main": [
[
{
"node": "Node 1",
"type": "main",
"index": 0
}
]
]
},
"Node 1": {
"main": [
[
{
"node": "Node 2",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"meta": {
"templateCredsSetupCompleted": true,
"instanceId": "178ef8a5109fc76c716d40bcadb720c455319f7b7a3fd5a39e4f336a091f524a"
}
}

View file

@ -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 .",

View file

@ -68,7 +68,10 @@ export class VariablesPage extends BasePage {
},
setRowValue: (row: Chainable<JQuery<HTMLElement>>, field: 'key' | 'value', value: string) => {
row.within(() => {
cy.getByTestId(`variable-row-${field}-input`).type('{selectAll}{del}').type(value);
cy.getByTestId(`variable-row-${field}-input`)
.find('input, textarea')
.type('{selectAll}{del}')
.type(value);
});
},
cancelRowEditing: (row: Chainable<JQuery<HTMLElement>>) => {

View file

@ -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,

View file

@ -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:

View file

@ -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);

View file

@ -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/*' }, [

View file

@ -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": [

View file

@ -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",

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/api-types",
"version": "0.12.0",
"version": "0.13.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -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;
};
};

View file

@ -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

View file

@ -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": {

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/config",
"version": "1.24.0",
"version": "1.26.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -107,7 +107,7 @@ class MysqlConfig {
}
@Config
class SqliteConfig {
export class SqliteConfig {
/** SQLite database file name */
@Env('DB_SQLITE_DATABASE')
database: string = 'database.sqlite';

View file

@ -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 */

View file

@ -1,2 +1,7 @@
/** @type {import('jest').Config} */
module.exports = require('../../../jest.config');
module.exports = {
...require('../../../jest.config'),
transform: {
'^.+\\.ts$': ['ts-jest', { isolatedModules: false }],
},
};

View file

@ -1,6 +1,6 @@
{
"name": "@n8n/di",
"version": "0.2.0",
"version": "0.3.0",
"scripts": {
"clean": "rimraf dist .turbo",
"dev": "pnpm watch",

View file

@ -0,0 +1,17 @@
import { ServiceA } from './fixtures/ServiceA';
import { ServiceB } from './fixtures/ServiceB';
import { Container } from '../di';
describe('DI Container', () => {
describe('circular dependency', () => {
it('should detect multilevel circular dependencies', () => {
expect(() => Container.get(ServiceA)).toThrow(
'[DI] Circular dependency detected in ServiceB at index 0.\nServiceA -> ServiceB',
);
expect(() => Container.get(ServiceB)).toThrow(
'[DI] Circular dependency detected in ServiceB at index 0.\nServiceB',
);
});
});
});

View file

@ -0,0 +1,8 @@
// eslint-disable-next-line import/no-cycle
import { ServiceB } from './ServiceB';
import { Service } from '../../di';
@Service()
export class ServiceA {
constructor(readonly b: ServiceB) {}
}

View file

@ -0,0 +1,8 @@
// eslint-disable-next-line import/no-cycle
import { ServiceA } from './ServiceA';
import { Service } from '../../di';
@Service()
export class ServiceB {
constructor(readonly a: ServiceA) {}
}

View file

@ -78,13 +78,6 @@ class ContainerClass {
if (metadata?.instance) return metadata.instance as T;
// Check for circular dependencies before proceeding with instantiation
if (resolutionStack.includes(type)) {
throw new DIError(
`Circular dependency detected. ${resolutionStack.map((t) => t.name).join(' -> ')}`,
);
}
// Add current type to resolution stack before resolving dependencies
resolutionStack.push(type);
@ -96,9 +89,15 @@ class ContainerClass {
} else {
const paramTypes = (Reflect.getMetadata('design:paramtypes', type) ??
[]) as Constructable[];
const dependencies = paramTypes.map(<P>(paramType: Constructable<P>) =>
this.get(paramType),
);
const dependencies = paramTypes.map(<P>(paramType: Constructable<P>, index: number) => {
if (paramType === undefined) {
throw new DIError(
`Circular dependency detected in ${type.name} at index ${index}.\n${resolutionStack.map((t) => t.name).join(' -> ')}\n`,
);
}
return this.get(paramType);
});
// Create new instance with resolved dependencies
instance = new (type as Constructable)(...dependencies) as T;
}

View file

@ -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: [

View file

@ -11,18 +11,25 @@ import {
import { getConnectionHintNoticeField } from '@utils/sharedFields';
import { searchModels } from './methods/loadModels';
import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling';
import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler';
import { N8nLlmTracing } from '../N8nLlmTracing';
export class LmChatOpenAi implements INodeType {
methods = {
listSearch: {
searchModels,
},
};
description: INodeTypeDescription = {
displayName: 'OpenAI Chat Model',
// eslint-disable-next-line n8n-nodes-base/node-class-description-name-miscased
name: 'lmChatOpenAi',
icon: { light: 'file:openAiLight.svg', dark: 'file:openAiLight.dark.svg' },
group: ['transform'],
version: [1, 1.1],
version: [1, 1.1, 1.2],
description: 'For advanced usage with an AI chain',
defaults: {
name: 'OpenAI Chat Model',
@ -130,6 +137,42 @@ export class LmChatOpenAi implements INodeType {
},
},
default: 'gpt-4o-mini',
displayOptions: {
hide: {
'@version': [{ _cnd: { gte: 1.2 } }],
},
},
},
{
displayName: 'Model',
name: 'model',
type: 'resourceLocator',
default: { mode: 'list', value: 'gpt-4o-mini' },
required: true,
modes: [
{
displayName: 'From List',
name: 'list',
type: 'list',
placeholder: 'Select a model...',
typeOptions: {
searchListMethod: 'searchModels',
searchable: true,
},
},
{
displayName: 'ID',
name: 'id',
type: 'string',
placeholder: 'gpt-4o-mini',
},
],
description: 'The model. Choose from the list, or specify an ID.',
displayOptions: {
hide: {
'@version': [{ _cnd: { lte: 1.1 } }],
},
},
},
{
displayName:
@ -251,7 +294,12 @@ export class LmChatOpenAi implements INodeType {
async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise<SupplyData> {
const credentials = await this.getCredentials('openAiApi');
const modelName = this.getNodeParameter('model', itemIndex) as string;
const version = this.getNode().typeVersion;
const modelName =
version >= 1.2
? (this.getNodeParameter('model.value', itemIndex) as string)
: (this.getNodeParameter('model', itemIndex) as string);
const options = this.getNodeParameter('options', itemIndex, {}) as {
baseURL?: string;
frequencyPenalty?: number;

View file

@ -0,0 +1,112 @@
import type { ILoadOptionsFunctions } from 'n8n-workflow';
import OpenAI from 'openai';
import { searchModels } from '../loadModels';
jest.mock('openai');
describe('searchModels', () => {
let mockContext: jest.Mocked<ILoadOptionsFunctions>;
let mockOpenAI: jest.Mocked<typeof OpenAI>;
beforeEach(() => {
mockContext = {
getCredentials: jest.fn().mockResolvedValue({
apiKey: 'test-api-key',
}),
getNodeParameter: jest.fn().mockReturnValue(''),
} as unknown as jest.Mocked<ILoadOptionsFunctions>;
// Setup OpenAI mock with required properties
const mockOpenAIInstance = {
apiKey: 'test-api-key',
organization: null,
project: null,
_options: {},
models: {
list: jest.fn().mockResolvedValue({
data: [
{ id: 'gpt-4' },
{ id: 'gpt-3.5-turbo' },
{ id: 'gpt-3.5-turbo-instruct' },
{ id: 'ft:gpt-3.5-turbo' },
{ id: 'o1-model' },
{ id: 'other-model' },
],
}),
},
} as unknown as OpenAI;
(OpenAI as jest.MockedClass<typeof OpenAI>).mockImplementation(() => mockOpenAIInstance);
mockOpenAI = OpenAI as jest.Mocked<typeof OpenAI>;
});
afterEach(() => {
jest.clearAllMocks();
});
it('should return filtered models if custom API endpoint is not provided', async () => {
const result = await searchModels.call(mockContext);
expect(mockOpenAI).toHaveBeenCalledWith({
baseURL: 'https://api.openai.com/v1',
apiKey: 'test-api-key',
});
expect(result.results).toHaveLength(4);
});
it('should initialize OpenAI with correct credentials', async () => {
mockContext.getCredentials.mockResolvedValueOnce({
apiKey: 'test-api-key',
url: 'https://test-url.com',
});
await searchModels.call(mockContext);
expect(mockOpenAI).toHaveBeenCalledWith({
baseURL: 'https://test-url.com',
apiKey: 'test-api-key',
});
});
it('should use default OpenAI URL if no custom URL provided', async () => {
mockContext.getCredentials = jest.fn().mockResolvedValue({
apiKey: 'test-api-key',
});
await searchModels.call(mockContext);
expect(mockOpenAI).toHaveBeenCalledWith({
baseURL: 'https://api.openai.com/v1',
apiKey: 'test-api-key',
});
});
it('should include all models for custom API endpoints', async () => {
mockContext.getNodeParameter = jest.fn().mockReturnValue('https://custom-api.com');
const result = await searchModels.call(mockContext);
expect(result.results).toHaveLength(6);
});
it('should filter models based on search term', async () => {
const result = await searchModels.call(mockContext, 'gpt');
expect(result.results).toEqual([
{ name: 'gpt-4', value: 'gpt-4' },
{ name: 'gpt-3.5-turbo', value: 'gpt-3.5-turbo' },
{ name: 'ft:gpt-3.5-turbo', value: 'ft:gpt-3.5-turbo' },
]);
});
it('should handle case-insensitive search', async () => {
const result = await searchModels.call(mockContext, 'GPT');
expect(result.results).toEqual([
{ name: 'gpt-4', value: 'gpt-4' },
{ name: 'gpt-3.5-turbo', value: 'gpt-3.5-turbo' },
{ name: 'ft:gpt-3.5-turbo', value: 'ft:gpt-3.5-turbo' },
]);
});
});

View file

@ -0,0 +1,37 @@
import type { ILoadOptionsFunctions, INodeListSearchResult } from 'n8n-workflow';
import OpenAI from 'openai';
export async function searchModels(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
const credentials = await this.getCredentials('openAiApi');
const baseURL =
(this.getNodeParameter('options.baseURL', '') as string) ||
(credentials.url as string) ||
'https://api.openai.com/v1';
const openai = new OpenAI({ baseURL, apiKey: credentials.apiKey as string });
const { data: models = [] } = await openai.models.list();
const filteredModels = models.filter((model: { id: string }) => {
const isValidModel =
(baseURL && !baseURL.includes('api.openai.com')) ||
model.id.startsWith('ft:') ||
model.id.startsWith('o1') ||
(model.id.startsWith('gpt-') && !model.id.includes('instruct'));
if (!filter) return isValidModel;
return isValidModel && model.id.toLowerCase().includes(filter.toLowerCase());
});
const results = {
results: filteredModels.map((model: { id: string }) => ({
name: model.id,
value: model.id,
})),
};
return results;
}

View file

@ -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,

View file

@ -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),
};
}

View file

@ -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';

View file

@ -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];

View file

@ -76,9 +76,15 @@ export async function modelSearch(
this: ILoadOptionsFunctions,
filter?: string,
): Promise<INodeListSearchResult> {
const credentials = await this.getCredentials<{ url: string }>('openAiApi');
const isCustomAPI = credentials.url && !credentials.url.includes('api.openai.com');
return await getModelSearch(
(model) =>
model.id.startsWith('gpt-') || model.id.startsWith('ft:') || model.id.startsWith('o1'),
isCustomAPI ||
model.id.startsWith('gpt-') ||
model.id.startsWith('ft:') ||
model.id.startsWith('o1'),
)(this, filter);
}

View file

@ -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",

View file

@ -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"
}
}

View file

@ -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:"
}
}

View file

@ -1,4 +1,4 @@
import { DateTime } from 'luxon';
import { DateTime, Duration, Interval } from 'luxon';
import type { IBinaryData } from 'n8n-workflow';
import { setGlobalState, type CodeExecutionMode, type IDataObject } from 'n8n-workflow';
import fs from 'node:fs';
@ -1342,4 +1342,98 @@ describe('JsTaskRunner', () => {
task.cleanup();
});
});
describe('prototype pollution prevention', () => {
const checkPrototypeIntact = () => {
const obj: Record<string, unknown> = {};
expect(obj.maliciousKey).toBeUndefined();
};
test('Object.setPrototypeOf should no-op for local object', async () => {
checkPrototypeIntact();
const outcome = await executeForAllItems({
code: `
const obj = {};
Object.setPrototypeOf(obj, { maliciousKey: 'value' });
return [{ json: { prototypeChanged: obj.maliciousKey !== undefined } }];
`,
inputItems: [{ a: 1 }],
});
expect(outcome.result).toEqual([wrapIntoJson({ prototypeChanged: false })]);
checkPrototypeIntact();
});
test('Reflect.setPrototypeOf should no-op for local object', async () => {
checkPrototypeIntact();
const outcome = await executeForAllItems({
code: `
const obj = {};
Reflect.setPrototypeOf(obj, { maliciousKey: 'value' });
return [{ json: { prototypeChanged: obj.maliciousKey !== undefined } }];
`,
inputItems: [{ a: 1 }],
});
expect(outcome.result).toEqual([wrapIntoJson({ prototypeChanged: false })]);
checkPrototypeIntact();
});
test('Object.setPrototypeOf should no-op for incoming object', async () => {
checkPrototypeIntact();
const outcome = await executeForAllItems({
code: `
const obj = $input.first();
Object.setPrototypeOf(obj, { maliciousKey: 'value' });
return [{ json: { prototypeChanged: obj.maliciousKey !== undefined } }];
`,
inputItems: [{ a: 1 }],
});
expect(outcome.result).toEqual([wrapIntoJson({ prototypeChanged: false })]);
checkPrototypeIntact();
});
test('Reflect.setPrototypeOf should no-op for incoming object', async () => {
checkPrototypeIntact();
const outcome = await executeForAllItems({
code: `
const obj = $input.first();
Reflect.setPrototypeOf(obj, { maliciousKey: 'value' });
return [{ json: { prototypeChanged: obj.maliciousKey !== undefined } }];
`,
inputItems: [{ a: 1 }],
});
expect(outcome.result).toEqual([wrapIntoJson({ prototypeChanged: false })]);
checkPrototypeIntact();
});
test('should freeze luxon prototypes', async () => {
const outcome = await executeForAllItems({
code: `
[DateTime, Interval, Duration]
.forEach(constructor => {
constructor.prototype.maliciousKey = 'value';
});
return []
`,
inputItems: [{ a: 1 }],
});
expect(outcome.result).toEqual([]);
// @ts-expect-error Non-existing property
expect(DateTime.now().maliciousKey).toBeUndefined();
// @ts-expect-error Non-existing property
expect(Interval.fromISO('P1Y2M10DT2H30M').maliciousKey).toBeUndefined();
// @ts-expect-error Non-existing property
expect(Duration.fromObject({ hours: 1 }).maliciousKey).toBeUndefined();
});
});
});

View file

@ -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'",
);
});
});
});

View file

@ -1,6 +1,7 @@
import set from 'lodash/set';
import { DateTime, Duration, Interval } from 'luxon';
import { getAdditionalKeys } from 'n8n-core';
import { WorkflowDataProxy, Workflow, ObservableObject } from 'n8n-workflow';
import { WorkflowDataProxy, Workflow, ObservableObject, Expression } from 'n8n-workflow';
import type {
CodeExecutionMode,
IWorkflowExecuteAdditionalData,
@ -97,12 +98,55 @@ export class JsTaskRunner extends TaskRunner {
const { jsRunnerConfig } = config;
const parseModuleAllowList = (moduleList: string) =>
moduleList === '*' ? null : new Set(moduleList.split(',').map((x) => x.trim()));
moduleList === '*'
? '*'
: new Set(
moduleList
.split(',')
.map((x) => x.trim())
.filter((x) => x !== ''),
);
const allowedBuiltInModules = parseModuleAllowList(jsRunnerConfig.allowedBuiltInModules ?? '');
const allowedExternalModules = parseModuleAllowList(
jsRunnerConfig.allowedExternalModules ?? '',
);
this.requireResolver = createRequireResolver({
allowedBuiltInModules: parseModuleAllowList(jsRunnerConfig.allowedBuiltInModules ?? ''),
allowedExternalModules: parseModuleAllowList(jsRunnerConfig.allowedExternalModules ?? ''),
allowedBuiltInModules,
allowedExternalModules,
});
this.preventPrototypePollution(allowedExternalModules);
}
private preventPrototypePollution(allowedExternalModules: Set<string> | '*') {
if (allowedExternalModules instanceof Set) {
// This is a workaround to enable the allowed external libraries to mutate
// prototypes directly. For example momentjs overrides .toString() directly
// on the Moment.prototype, which doesn't work if Object.prototype has been
// frozen. This works as long as the overrides are done when the library is
// imported.
for (const module of allowedExternalModules) {
require(module);
}
}
// Freeze globals, except for Jest
if (process.env.NODE_ENV !== 'test') {
Object.getOwnPropertyNames(globalThis)
// @ts-expect-error globalThis does not have string in index signature
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
.map((name) => globalThis[name])
.filter((value) => typeof value === 'function')
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
.forEach((fn) => Object.freeze(fn.prototype));
}
// Freeze internal classes
[Workflow, Expression, WorkflowDataProxy, DateTime, Interval, Duration]
.map((constructor) => constructor.prototype)
.forEach(Object.freeze);
}
async executeTask(
@ -203,8 +247,11 @@ export class JsTaskRunner extends TaskRunner {
signal.addEventListener('abort', abortHandler, { once: true });
const preventPrototypeManipulation =
'Object.getPrototypeOf = () => ({}); Reflect.getPrototypeOf = () => ({}); Object.setPrototypeOf = () => false; Reflect.setPrototypeOf = () => false;';
const taskResult = runInContext(
`globalThis.global = globalThis; module.exports = async function VmCodeWrapper() {${settings.code}\n}()`,
`globalThis.global = globalThis; ${preventPrototypeManipulation}; module.exports = async function VmCodeWrapper() {${settings.code}\n}()`,
context,
{ timeout: this.taskTimeout * 1000 },
) as Promise<TaskResultData['result']>;
@ -461,7 +508,7 @@ export class JsTaskRunner extends TaskRunner {
* @param dataProxy The data proxy object that provides access to built-ins
* @param additionalProperties Additional properties to add to the context
*/
private buildContext(
buildContext(
taskId: string,
workflow: Workflow,
node: INode,

View file

@ -6,15 +6,15 @@ import { ExecutionError } from './errors/execution-error';
export type RequireResolverOpts = {
/**
* List of built-in nodejs modules that are allowed to be required in the
* execution sandbox. `null` means all are allowed.
* execution sandbox. `"*"` means all are allowed.
*/
allowedBuiltInModules: Set<string> | null;
allowedBuiltInModules: Set<string> | '*';
/**
* List of external modules that are allowed to be required in the
* execution sandbox. `null` means all are allowed.
* execution sandbox. `"*"` means all are allowed.
*/
allowedExternalModules: Set<string> | null;
allowedExternalModules: Set<string> | '*';
};
export type RequireResolver = (request: string) => unknown;
@ -24,8 +24,8 @@ export function createRequireResolver({
allowedExternalModules,
}: RequireResolverOpts) {
return (request: string) => {
const checkIsAllowed = (allowList: Set<string> | null, moduleName: string) => {
return allowList ? allowList.has(moduleName) : true;
const checkIsAllowed = (allowList: Set<string> | '*', moduleName: string) => {
return allowList === '*' || allowList.has(moduleName);
};
const isAllowed = isBuiltin(request)

View file

@ -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',
// ******************************************************************

View file

@ -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',

View file

@ -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();
}`,
},
],
});

View file

@ -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:",

View file

@ -251,6 +251,20 @@ describe('License', () => {
expect(LicenseManager).toHaveBeenCalledWith(expect.objectContaining(expectedRenewalSettings));
});
it('when CLI command with N8N_LICENSE_AUTO_RENEW_ENABLED=true, should enable renewal', async () => {
const globalConfig = mock<GlobalConfig>({
license: { ...licenseConfig, autoRenewalEnabled: true },
});
await new License(mockLogger(), mock(), mock(), mock(), globalConfig).init({
isCli: true,
});
expect(LicenseManager).toHaveBeenCalledWith(
expect.objectContaining({ autoRenewEnabled: true, renewOnInit: true }),
);
});
});
describe('reinit', () => {
@ -262,7 +276,7 @@ describe('License', () => {
await license.reinit();
expect(initSpy).toHaveBeenCalledWith(true);
expect(initSpy).toHaveBeenCalledWith({ forceRecreate: true });
expect(LicenseManager.prototype.reset).toHaveBeenCalled();
expect(LicenseManager.prototype.initialize).toHaveBeenCalled();

View file

@ -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);

View file

@ -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);
}
/**

View file

@ -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' });

View file

@ -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 {

View file

@ -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());
}

View file

@ -1,6 +1,7 @@
import { mock } from 'jest-mock-extended';
import type { WorkflowExecuteMode as ExecutionMode } from 'n8n-workflow';
import type { ConcurrencyQueueType } from '@/concurrency/concurrency-control.service';
import {
CLOUD_TEMP_PRODUCTION_LIMIT,
CLOUD_TEMP_REPORTABLE_THRESHOLDS,
@ -24,61 +25,71 @@ describe('ConcurrencyControlService', () => {
afterEach(() => {
config.set('executions.concurrency.productionLimit', -1);
config.set('executions.concurrency.evaluationLimit', -1);
config.set('executions.mode', 'integrated');
jest.clearAllMocks();
});
describe('constructor', () => {
it('should be enabled if production cap is positive', () => {
/**
* Arrange
*/
config.set('executions.concurrency.productionLimit', 1);
it.each(['production', 'evaluation'])(
'should be enabled if %s cap is positive',
(type: ConcurrencyQueueType) => {
/**
* Arrange
*/
config.set(`executions.concurrency.${type}Limit`, 1);
/**
* Act
*/
const service = new ConcurrencyControlService(
logger,
executionRepository,
telemetry,
eventService,
);
/**
* Assert
*/
// @ts-expect-error Private property
expect(service.isEnabled).toBe(true);
// @ts-expect-error Private property
expect(service.productionQueue).toBeDefined();
});
it('should throw if production cap is 0', () => {
/**
* Arrange
*/
config.set('executions.concurrency.productionLimit', 0);
try {
/**
* Act
*/
new ConcurrencyControlService(logger, executionRepository, telemetry, eventService);
} catch (error) {
const service = new ConcurrencyControlService(
logger,
executionRepository,
telemetry,
eventService,
);
/**
* Assert
*/
expect(error).toBeInstanceOf(InvalidConcurrencyLimitError);
}
});
// @ts-expect-error Private property
expect(service.isEnabled).toBe(true);
// @ts-expect-error Private property
expect(service.queues.get(type)).toBeDefined();
// @ts-expect-error Private property
expect(service.queues.size).toBe(1);
},
);
it('should be disabled if production cap is -1', () => {
it.each(['production', 'evaluation'])(
'should throw if %s cap is 0',
(type: ConcurrencyQueueType) => {
/**
* Arrange
*/
config.set(`executions.concurrency.${type}Limit`, 0);
try {
/**
* Act
*/
new ConcurrencyControlService(logger, executionRepository, telemetry, eventService);
} catch (error) {
/**
* Assert
*/
expect(error).toBeInstanceOf(InvalidConcurrencyLimitError);
}
},
);
it('should be disabled if both production and evaluation caps are -1', () => {
/**
* Arrange
*/
config.set('executions.concurrency.productionLimit', -1);
config.set('executions.concurrency.evaluationLimit', -1);
/**
* Act
@ -97,28 +108,31 @@ describe('ConcurrencyControlService', () => {
expect(service.isEnabled).toBe(false);
});
it('should be disabled if production cap is lower than -1', () => {
/**
* Arrange
*/
config.set('executions.concurrency.productionLimit', -2);
it.each(['production', 'evaluation'])(
'should be disabled if %s cap is lower than -1',
(type: ConcurrencyQueueType) => {
/**
* Arrange
*/
config.set(`executions.concurrency.${type}Limit`, -2);
/**
* Act
*/
const service = new ConcurrencyControlService(
logger,
executionRepository,
telemetry,
eventService,
);
/**
* Act
*/
const service = new ConcurrencyControlService(
logger,
executionRepository,
telemetry,
eventService,
);
/**
* Act
*/
// @ts-expect-error Private property
expect(service.isEnabled).toBe(false);
});
/**
* Act
*/
// @ts-expect-error Private property
expect(service.isEnabled).toBe(false);
},
);
it('should be disabled on queue mode', () => {
/**
@ -203,6 +217,31 @@ describe('ConcurrencyControlService', () => {
*/
expect(enqueueSpy).toHaveBeenCalled();
});
it('should enqueue on evaluation mode', async () => {
/**
* Arrange
*/
config.set('executions.concurrency.evaluationLimit', 1);
const service = new ConcurrencyControlService(
logger,
executionRepository,
telemetry,
eventService,
);
const enqueueSpy = jest.spyOn(ConcurrencyQueue.prototype, 'enqueue');
/**
* Act
*/
await service.throttle({ mode: 'evaluation', executionId: '1' });
/**
* Assert
*/
expect(enqueueSpy).toHaveBeenCalled();
});
});
describe('release', () => {
@ -258,6 +297,31 @@ describe('ConcurrencyControlService', () => {
*/
expect(dequeueSpy).toHaveBeenCalled();
});
it('should dequeue on evaluation mode', () => {
/**
* Arrange
*/
config.set('executions.concurrency.evaluationLimit', 1);
const service = new ConcurrencyControlService(
logger,
executionRepository,
telemetry,
eventService,
);
const dequeueSpy = jest.spyOn(ConcurrencyQueue.prototype, 'dequeue');
/**
* Act
*/
service.release({ mode: 'evaluation' });
/**
* Assert
*/
expect(dequeueSpy).toHaveBeenCalled();
});
});
describe('remove', () => {
@ -316,14 +380,12 @@ describe('ConcurrencyControlService', () => {
expect(removeSpy).toHaveBeenCalled();
},
);
});
describe('removeAll', () => {
it('should remove all executions from the production queue', async () => {
it('should remove an execution on evaluation mode', () => {
/**
* Arrange
*/
config.set('executions.concurrency.productionLimit', 2);
config.set('executions.concurrency.evaluationLimit', 1);
const service = new ConcurrencyControlService(
logger,
@ -331,28 +393,112 @@ describe('ConcurrencyControlService', () => {
telemetry,
eventService,
);
jest
.spyOn(ConcurrencyQueue.prototype, 'getAll')
.mockReturnValueOnce(new Set(['1', '2', '3']));
const removeSpy = jest.spyOn(ConcurrencyQueue.prototype, 'remove');
/**
* Act
*/
await service.removeAll({
'1': mock<IExecutingWorkflowData>(),
'2': mock<IExecutingWorkflowData>(),
'3': mock<IExecutingWorkflowData>(),
});
service.remove({ mode: 'evaluation', executionId: '1' });
/**
* Assert
*/
expect(removeSpy).toHaveBeenNthCalledWith(1, '1');
expect(removeSpy).toHaveBeenNthCalledWith(2, '2');
expect(removeSpy).toHaveBeenNthCalledWith(3, '3');
expect(removeSpy).toHaveBeenCalled();
});
});
describe('removeAll', () => {
it.each(['production', 'evaluation'])(
'should remove all executions from the %s queue',
async (type: ConcurrencyQueueType) => {
/**
* Arrange
*/
config.set(`executions.concurrency.${type}Limit`, 2);
const service = new ConcurrencyControlService(
logger,
executionRepository,
telemetry,
eventService,
);
jest
.spyOn(ConcurrencyQueue.prototype, 'getAll')
.mockReturnValueOnce(new Set(['1', '2', '3']));
const removeSpy = jest.spyOn(ConcurrencyQueue.prototype, 'remove');
/**
* Act
*/
await service.removeAll({
'1': mock<IExecutingWorkflowData>(),
'2': mock<IExecutingWorkflowData>(),
'3': mock<IExecutingWorkflowData>(),
});
/**
* Assert
*/
expect(removeSpy).toHaveBeenNthCalledWith(1, '1');
expect(removeSpy).toHaveBeenNthCalledWith(2, '2');
expect(removeSpy).toHaveBeenNthCalledWith(3, '3');
},
);
});
describe('get queue', () => {
it('should choose the production queue', async () => {
/**
* Arrange
*/
config.set('executions.concurrency.productionLimit', 2);
config.set('executions.concurrency.evaluationLimit', 2);
/**
* Act
*/
const service = new ConcurrencyControlService(
logger,
executionRepository,
telemetry,
eventService,
);
// @ts-expect-error Private property
const queue = service.getQueue('webhook');
/**
* Assert
*/
// @ts-expect-error Private property
expect(queue).toEqual(service.queues.get('production'));
});
it('should choose the evaluation queue', async () => {
/**
* Arrange
*/
config.set('executions.concurrency.productionLimit', 2);
config.set('executions.concurrency.evaluationLimit', 2);
/**
* Act
*/
const service = new ConcurrencyControlService(
logger,
executionRepository,
telemetry,
eventService,
);
// @ts-expect-error Private property
const queue = service.getQueue('evaluation');
/**
* Assert
*/
// @ts-expect-error Private property
expect(queue).toEqual(service.queues.get('evaluation'));
});
});
});
@ -388,6 +534,32 @@ describe('ConcurrencyControlService', () => {
*/
expect(enqueueSpy).not.toHaveBeenCalled();
});
it('should do nothing for evaluation executions', async () => {
/**
* Arrange
*/
config.set('executions.concurrency.evaluationLimit', -1);
const service = new ConcurrencyControlService(
logger,
executionRepository,
telemetry,
eventService,
);
const enqueueSpy = jest.spyOn(ConcurrencyQueue.prototype, 'enqueue');
/**
* Act
*/
await service.throttle({ mode: 'evaluation', executionId: '1' });
await service.throttle({ mode: 'evaluation', executionId: '2' });
/**
* Assert
*/
expect(enqueueSpy).not.toHaveBeenCalled();
});
});
describe('release', () => {
@ -415,6 +587,31 @@ describe('ConcurrencyControlService', () => {
*/
expect(dequeueSpy).not.toHaveBeenCalled();
});
it('should do nothing for evaluation executions', () => {
/**
* Arrange
*/
config.set('executions.concurrency.evaluationLimit', -1);
const service = new ConcurrencyControlService(
logger,
executionRepository,
telemetry,
eventService,
);
const dequeueSpy = jest.spyOn(ConcurrencyQueue.prototype, 'dequeue');
/**
* Act
*/
service.release({ mode: 'evaluation' });
/**
* Assert
*/
expect(dequeueSpy).not.toHaveBeenCalled();
});
});
describe('remove', () => {
@ -442,6 +639,31 @@ describe('ConcurrencyControlService', () => {
*/
expect(removeSpy).not.toHaveBeenCalled();
});
it('should do nothing for evaluation executions', () => {
/**
* Arrange
*/
config.set('executions.concurrency.evaluationLimit', -1);
const service = new ConcurrencyControlService(
logger,
executionRepository,
telemetry,
eventService,
);
const removeSpy = jest.spyOn(ConcurrencyQueue.prototype, 'remove');
/**
* Act
*/
service.remove({ mode: 'evaluation', executionId: '1' });
/**
* Assert
*/
expect(removeSpy).not.toHaveBeenCalled();
});
});
});
@ -470,14 +692,17 @@ describe('ConcurrencyControlService', () => {
* Act
*/
// @ts-expect-error Private property
service.productionQueue.emit('concurrency-check', {
service.queues.get('production').emit('concurrency-check', {
capacity: CLOUD_TEMP_PRODUCTION_LIMIT - threshold,
});
/**
* Assert
*/
expect(telemetry.track).toHaveBeenCalledWith('User hit concurrency limit', { threshold });
expect(telemetry.track).toHaveBeenCalledWith('User hit concurrency limit', {
threshold,
concurrencyQueue: 'production',
});
},
);
@ -500,7 +725,7 @@ describe('ConcurrencyControlService', () => {
* Act
*/
// @ts-expect-error Private property
service.productionQueue.emit('concurrency-check', {
service.queues.get('production').emit('concurrency-check', {
capacity: CLOUD_TEMP_PRODUCTION_LIMIT - threshold,
});
@ -532,7 +757,7 @@ describe('ConcurrencyControlService', () => {
* Act
*/
// @ts-expect-error Private property
service.productionQueue.emit('concurrency-check', {
service.queues.get('production').emit('concurrency-check', {
capacity: CLOUD_TEMP_PRODUCTION_LIMIT - threshold,
});

View file

@ -1,4 +1,5 @@
import { Service } from '@n8n/di';
import { capitalize } from 'lodash';
import { Logger } from 'n8n-core';
import type { WorkflowExecuteMode as ExecutionMode } from 'n8n-workflow';
@ -15,13 +16,15 @@ import { ConcurrencyQueue } from './concurrency-queue';
export const CLOUD_TEMP_PRODUCTION_LIMIT = 999;
export const CLOUD_TEMP_REPORTABLE_THRESHOLDS = [5, 10, 20, 50, 100, 200];
export type ConcurrencyQueueType = 'production' | 'evaluation';
@Service()
export class ConcurrencyControlService {
private isEnabled: boolean;
private readonly productionLimit: number;
private readonly limits: Map<ConcurrencyQueueType, number>;
private readonly productionQueue: ConcurrencyQueue;
private readonly queues: Map<ConcurrencyQueueType, ConcurrencyQueue>;
private readonly limitsToReport = CLOUD_TEMP_REPORTABLE_THRESHOLDS.map(
(t) => CLOUD_TEMP_PRODUCTION_LIMIT - t,
@ -35,52 +38,74 @@ export class ConcurrencyControlService {
) {
this.logger = this.logger.scoped('concurrency');
this.productionLimit = config.getEnv('executions.concurrency.productionLimit');
this.limits = new Map([
['production', config.getEnv('executions.concurrency.productionLimit')],
['evaluation', config.getEnv('executions.concurrency.evaluationLimit')],
]);
if (this.productionLimit === 0) {
throw new InvalidConcurrencyLimitError(this.productionLimit);
}
this.limits.forEach((limit, type) => {
if (limit === 0) {
throw new InvalidConcurrencyLimitError(limit);
}
if (this.productionLimit < -1) {
this.productionLimit = -1;
}
if (limit < -1) {
this.limits.set(type, -1);
}
});
if (this.productionLimit === -1 || config.getEnv('executions.mode') === 'queue') {
if (
Array.from(this.limits.values()).every((limit) => limit === -1) ||
config.getEnv('executions.mode') === 'queue'
) {
this.isEnabled = false;
return;
}
this.productionQueue = new ConcurrencyQueue(this.productionLimit);
this.queues = new Map();
this.limits.forEach((limit, type) => {
if (limit > 0) {
this.queues.set(type, new ConcurrencyQueue(limit));
}
});
this.logInit();
this.isEnabled = true;
this.productionQueue.on('concurrency-check', ({ capacity }) => {
if (this.shouldReport(capacity)) {
this.telemetry.track('User hit concurrency limit', {
threshold: CLOUD_TEMP_PRODUCTION_LIMIT - capacity,
});
}
});
this.queues.forEach((queue, type) => {
queue.on('concurrency-check', ({ capacity }) => {
if (this.shouldReport(capacity)) {
this.telemetry.track('User hit concurrency limit', {
threshold: CLOUD_TEMP_PRODUCTION_LIMIT - capacity,
concurrencyQueue: type,
});
}
});
this.productionQueue.on('execution-throttled', ({ executionId }) => {
this.logger.debug('Execution throttled', { executionId });
this.eventService.emit('execution-throttled', { executionId });
});
queue.on('execution-throttled', ({ executionId }) => {
this.logger.debug('Execution throttled', { executionId, type });
this.eventService.emit('execution-throttled', { executionId, type });
});
this.productionQueue.on('execution-released', async (executionId) => {
this.logger.debug('Execution released', { executionId });
queue.on('execution-released', (executionId) => {
this.logger.debug('Execution released', { executionId, type });
});
});
}
/**
* Check whether an execution is in the production queue.
* Check whether an execution is in any of the queues.
*/
has(executionId: string) {
if (!this.isEnabled) return false;
return this.productionQueue.getAll().has(executionId);
for (const queue of this.queues.values()) {
if (queue.has(executionId)) {
return true;
}
}
return false;
}
/**
@ -89,16 +114,16 @@ export class ConcurrencyControlService {
async throttle({ mode, executionId }: { mode: ExecutionMode; executionId: string }) {
if (!this.isEnabled || this.isUnlimited(mode)) return;
await this.productionQueue.enqueue(executionId);
await this.getQueue(mode)?.enqueue(executionId);
}
/**
* Release capacity back so the next execution in the production queue can proceed.
* Release capacity back so the next execution in the queue can proceed.
*/
release({ mode }: { mode: ExecutionMode }) {
if (!this.isEnabled || this.isUnlimited(mode)) return;
this.productionQueue.dequeue();
this.getQueue(mode)?.dequeue();
}
/**
@ -107,7 +132,7 @@ export class ConcurrencyControlService {
remove({ mode, executionId }: { mode: ExecutionMode; executionId: string }) {
if (!this.isEnabled || this.isUnlimited(mode)) return;
this.productionQueue.remove(executionId);
this.getQueue(mode)?.remove(executionId);
}
/**
@ -118,11 +143,13 @@ export class ConcurrencyControlService {
async removeAll(activeExecutions: { [executionId: string]: IExecutingWorkflowData }) {
if (!this.isEnabled) return;
const enqueuedProductionIds = this.productionQueue.getAll();
this.queues.forEach((queue) => {
const enqueuedExecutionIds = queue.getAll();
for (const id of enqueuedProductionIds) {
this.productionQueue.remove(id);
}
for (const id of enqueuedExecutionIds) {
queue.remove(id);
}
});
const executionIds = Object.entries(activeExecutions)
.filter(([_, execution]) => execution.status === 'new' && execution.responsePromise)
@ -146,15 +173,28 @@ export class ConcurrencyControlService {
private logInit() {
this.logger.debug('Enabled');
this.logger.debug(
[
'Production execution concurrency is',
this.productionLimit === -1 ? 'unlimited' : 'limited to ' + this.productionLimit.toString(),
].join(' '),
);
this.limits.forEach((limit, type) => {
this.logger.debug(
[
`${capitalize(type)} execution concurrency is`,
limit === -1 ? 'unlimited' : 'limited to ' + limit.toString(),
].join(' '),
);
});
}
private isUnlimited(mode: ExecutionMode) {
return this.getQueue(mode) === undefined;
}
private shouldReport(capacity: number) {
return config.getEnv('deployment.type') === 'cloud' && this.limitsToReport.includes(capacity);
}
/**
* Get the concurrency queue based on the execution mode.
*/
private getQueue(mode: ExecutionMode) {
if (
mode === 'error' ||
mode === 'integrated' ||
@ -163,15 +203,13 @@ export class ConcurrencyControlService {
mode === 'manual' ||
mode === 'retry'
) {
return true;
return undefined;
}
if (mode === 'webhook' || mode === 'trigger') return this.productionLimit === -1;
if (mode === 'webhook' || mode === 'trigger') return this.queues.get('production');
if (mode === 'evaluation') return this.queues.get('evaluation');
throw new UnknownExecutionModeError(mode);
}
private shouldReport(capacity: number) {
return config.getEnv('deployment.type') === 'cloud' && this.limitsToReport.includes(capacity);
}
}

View file

@ -58,6 +58,10 @@ export class ConcurrencyQueue extends TypedEmitter<ConcurrencyEvents> {
return new Set(this.queue.map((item) => item.executionId));
}
has(executionId: string) {
return this.queue.some((item) => item.executionId === executionId);
}
private resolveNext() {
const item = this.queue.shift();

View file

@ -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).

View file

@ -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' });
});
});
});

View file

@ -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,

View file

@ -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) => {

View file

@ -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,

View file

@ -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) {

View file

@ -7,7 +7,7 @@ import {
} from '@/databases/entities/abstract-entity';
import { TestDefinition } from '@/databases/entities/test-definition.ee';
type TestRunStatus = 'new' | 'running' | 'completed' | 'error';
type TestRunStatus = 'new' | 'running' | 'completed' | 'error' | 'cancelled';
export type AggregatedTestRunMetrics = Record<string, number | boolean>;

View file

@ -1,13 +1,16 @@
import { GlobalConfig } from '@n8n/config';
import type { SqliteConfig } from '@n8n/config/src/configs/database.config';
import { Container } from '@n8n/di';
import type { SelectQueryBuilder } from '@n8n/typeorm';
import { Not, LessThanOrEqual } from '@n8n/typeorm';
import { mock } from 'jest-mock-extended';
import { BinaryDataService } from 'n8n-core';
import type { IRunExecutionData, IWorkflowBase } from 'n8n-workflow';
import { nanoid } from 'nanoid';
import { ExecutionEntity } from '@/databases/entities/execution-entity';
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
import type { IExecutionResponse } from '@/interfaces';
import { mockInstance, mockEntityManager } from '@test/mocking';
describe('ExecutionRepository', () => {
@ -68,4 +71,39 @@ describe('ExecutionRepository', () => {
expect(binaryDataService.deleteMany).toHaveBeenCalledWith([{ executionId: '1', workflowId }]);
});
});
describe('updateExistingExecution', () => {
test.each(['sqlite', 'postgresdb', 'mysqldb'] as const)(
'should update execution and data in transaction on %s',
async (dbType) => {
globalConfig.database.type = dbType;
globalConfig.database.sqlite = mock<SqliteConfig>({ poolSize: 1 });
const executionId = '1';
const execution = mock<IExecutionResponse>({
id: executionId,
data: mock<IRunExecutionData>(),
workflowData: mock<IWorkflowBase>(),
status: 'success',
});
const txCallback = jest.fn();
entityManager.transaction.mockImplementation(async (cb) => {
// @ts-expect-error Mock
await cb(entityManager);
txCallback();
});
await executionRepository.updateExistingExecution(executionId, execution);
expect(entityManager.transaction).toHaveBeenCalled();
expect(entityManager.update).toHaveBeenCalledWith(
ExecutionEntity,
{ id: executionId },
expect.objectContaining({ status: 'success' }),
);
expect(txCallback).toHaveBeenCalledTimes(1);
},
);
});
});

View file

@ -45,7 +45,7 @@ import type {
import { separate } from '@/utils';
import { ExecutionDataRepository } from './execution-data.repository';
import type { ExecutionData } from '../entities/execution-data';
import { ExecutionData } from '../entities/execution-data';
import { ExecutionEntity } from '../entities/execution-entity';
import { ExecutionMetadata } from '../entities/execution-metadata';
import { SharedWorkflow } from '../entities/shared-workflow';
@ -287,6 +287,15 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
const { executionData, metadata, annotation, ...rest } = execution;
const serializedAnnotation = this.serializeAnnotation(annotation);
if (execution.status === 'success' && executionData?.data === '[]') {
this.errorReporter.error('Found successful execution where data is empty stringified array', {
extra: {
executionId: execution.id,
workflowId: executionData?.workflowData.id,
},
});
}
return {
...rest,
...(options?.includeData && {
@ -378,21 +387,42 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
customData,
...executionInformation
} = execution;
if (Object.keys(executionInformation).length > 0) {
await this.update({ id: executionId }, executionInformation);
const executionData: Partial<ExecutionData> = {};
if (workflowData) executionData.workflowData = workflowData;
if (data) executionData.data = stringify(data);
const { type: dbType, sqlite: sqliteConfig } = this.globalConfig.database;
if (dbType === 'sqlite' && sqliteConfig.poolSize === 0) {
// TODO: Delete this block of code once the sqlite legacy (non-pooling) driver is dropped.
// In the non-pooling sqlite driver we can't use transactions, because that creates nested transactions under highly concurrent loads, leading to errors in the database
if (Object.keys(executionInformation).length > 0) {
await this.update({ id: executionId }, executionInformation);
}
if (Object.keys(executionData).length > 0) {
// @ts-expect-error Fix typing
await this.executionDataRepository.update({ executionId }, executionData);
}
return;
}
if (data || workflowData) {
const executionData: Partial<ExecutionData> = {};
if (workflowData) {
executionData.workflowData = workflowData;
// All other database drivers should update executions and execution-data atomically
await this.manager.transaction(async (tx) => {
if (Object.keys(executionInformation).length > 0) {
await tx.update(ExecutionEntity, { id: executionId }, executionInformation);
}
if (data) {
executionData.data = stringify(data);
if (Object.keys(executionData).length > 0) {
// @ts-expect-error Fix typing
await tx.update(ExecutionData, { executionId }, executionData);
}
// @ts-ignore
await this.executionDataRepository.update({ executionId }, executionData);
}
});
}
async deleteExecutionsByFilter(

View file

@ -35,6 +35,10 @@ export class TestRunRepository extends Repository<TestRun> {
return await this.update(id, { status: 'completed', completedAt: new Date(), metrics });
}
async markAsCancelled(id: string) {
return await this.update(id, { status: 'cancelled' });
}
async incrementPassed(id: string) {
return await this.increment({ id }, 'passedCases', 1);
}

View file

@ -44,10 +44,12 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
});
}
async getActiveIds() {
async getActiveIds({ maxResults }: { maxResults?: number } = {}) {
const activeWorkflows = await this.find({
select: ['id'],
where: { active: true },
// 'take' and 'order' are only needed when maxResults is provided:
...(maxResults ? { take: maxResults, order: { createdAt: 'ASC' } } : {}),
});
return activeWorkflows.map((workflow) => workflow.id);
}

View file

@ -26,6 +26,9 @@ describe('SourceControlImportService', () => {
mock(),
workflowRepository,
mock(),
mock(),
mock(),
mock(),
mock<InstanceSettings>({ n8nFolder: '/mock/n8n' }),
);

View file

@ -1,10 +1,19 @@
import type { SourceControlledFile } from '@n8n/api-types';
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import { InstanceSettings } from 'n8n-core';
import type { TagEntity } from '@/databases/entities/tag-entity';
import type { User } from '@/databases/entities/user';
import type { Variables } from '@/databases/entities/variables';
import type { TagRepository } from '@/databases/repositories/tag.repository';
import { SourceControlPreferencesService } from '@/environments.ee/source-control/source-control-preferences.service.ee';
import { SourceControlService } from '@/environments.ee/source-control/source-control.service.ee';
import type { SourceControlImportService } from '../source-control-import.service.ee';
import type { ExportableCredential } from '../types/exportable-credential';
import type { SourceControlWorkflowVersionId } from '../types/source-control-workflow-version-id';
describe('SourceControlService', () => {
const preferencesService = new SourceControlPreferencesService(
Container.get(InstanceSettings),
@ -13,20 +22,25 @@ describe('SourceControlService', () => {
mock(),
mock(),
);
const sourceControlImportService = mock<SourceControlImportService>();
const tagRepository = mock<TagRepository>();
const sourceControlService = new SourceControlService(
mock(),
mock(),
preferencesService,
mock(),
mock(),
mock(),
sourceControlImportService,
tagRepository,
mock(),
);
beforeEach(() => {
jest.resetAllMocks();
jest.spyOn(sourceControlService, 'sanityCheck').mockResolvedValue(undefined);
});
describe('pushWorkfolder', () => {
it('should throw an error if a file is given that is not in the workfolder', async () => {
jest.spyOn(sourceControlService, 'sanityCheck').mockResolvedValue(undefined);
await expect(
sourceControlService.pushWorkfolder({
fileNames: [
@ -46,4 +60,155 @@ describe('SourceControlService', () => {
).rejects.toThrow('File path /etc/passwd is invalid');
});
});
describe('pullWorkfolder', () => {
it('does not filter locally created credentials', async () => {
// ARRANGE
const user = mock<User>();
const statuses = [
mock<SourceControlledFile>({
status: 'created',
location: 'local',
type: 'credential',
}),
mock<SourceControlledFile>({
status: 'created',
location: 'local',
type: 'workflow',
}),
];
jest.spyOn(sourceControlService, 'getStatus').mockResolvedValueOnce(statuses);
// ACT
const result = await sourceControlService.pullWorkfolder(user, {});
// ASSERT
expect(result).toMatchObject({ statusCode: 409, statusResult: statuses });
});
it('does not filter remotely deleted credentials', async () => {
// ARRANGE
const user = mock<User>();
const statuses = [
mock<SourceControlledFile>({
status: 'deleted',
location: 'remote',
type: 'credential',
}),
mock<SourceControlledFile>({
status: 'created',
location: 'local',
type: 'workflow',
}),
];
jest.spyOn(sourceControlService, 'getStatus').mockResolvedValueOnce(statuses);
// ACT
const result = await sourceControlService.pullWorkfolder(user, {});
// ASSERT
expect(result).toMatchObject({ statusCode: 409, statusResult: statuses });
});
it('should throw an error if a file is given that is not in the workfolder', async () => {
await expect(
sourceControlService.pushWorkfolder({
fileNames: [
{
file: '/etc/passwd',
id: 'test',
name: 'secret-file',
type: 'file',
status: 'modified',
location: 'local',
conflict: false,
updatedAt: new Date().toISOString(),
pushed: false,
},
],
}),
).rejects.toThrow('File path /etc/passwd is invalid');
});
});
describe('getStatus', () => {
it('conflict depends on the value of `direction`', async () => {
// ARRANGE
// Define a credential that does only exist locally.
// Pulling this would delete it so it should be marked as a conflict.
// Pushing this is conflict free.
sourceControlImportService.getRemoteVersionIdsFromFiles.mockResolvedValue([]);
sourceControlImportService.getLocalVersionIdsFromDb.mockResolvedValue([
mock<SourceControlWorkflowVersionId>(),
]);
// Define a credential that does only exist locally.
// Pulling this would delete it so it should be marked as a conflict.
// Pushing this is conflict free.
sourceControlImportService.getRemoteCredentialsFromFiles.mockResolvedValue([]);
sourceControlImportService.getLocalCredentialsFromDb.mockResolvedValue([
mock<ExportableCredential & { filename: string }>(),
]);
// Define a variable that does only exist locally.
// Pulling this would delete it so it should be marked as a conflict.
// Pushing this is conflict free.
sourceControlImportService.getRemoteVariablesFromFile.mockResolvedValue([]);
sourceControlImportService.getLocalVariablesFromDb.mockResolvedValue([mock<Variables>()]);
// Define a tag that does only exist locally.
// Pulling this would delete it so it should be marked as a conflict.
// Pushing this is conflict free.
const tag = mock<TagEntity>({ updatedAt: new Date() });
tagRepository.find.mockResolvedValue([tag]);
sourceControlImportService.getRemoteTagsAndMappingsFromFile.mockResolvedValue({
tags: [],
mappings: [],
});
sourceControlImportService.getLocalTagsAndMappingsFromDb.mockResolvedValue({
tags: [tag],
mappings: [],
});
// ACT
const pullResult = await sourceControlService.getStatus({
direction: 'pull',
verbose: false,
preferLocalVersion: false,
});
const pushResult = await sourceControlService.getStatus({
direction: 'push',
verbose: false,
preferLocalVersion: false,
});
// ASSERT
console.log(pullResult);
console.log(pushResult);
if (!Array.isArray(pullResult)) {
fail('Expected pullResult to be an array.');
}
if (!Array.isArray(pushResult)) {
fail('Expected pushResult to be an array.');
}
expect(pullResult).toHaveLength(4);
expect(pushResult).toHaveLength(4);
expect(pullResult.find((i) => i.type === 'workflow')).toHaveProperty('conflict', true);
expect(pushResult.find((i) => i.type === 'workflow')).toHaveProperty('conflict', false);
expect(pullResult.find((i) => i.type === 'credential')).toHaveProperty('conflict', true);
expect(pushResult.find((i) => i.type === 'credential')).toHaveProperty('conflict', false);
expect(pullResult.find((i) => i.type === 'variables')).toHaveProperty('conflict', true);
expect(pushResult.find((i) => i.type === 'variables')).toHaveProperty('conflict', false);
expect(pullResult.find((i) => i.type === 'tags')).toHaveProperty('conflict', true);
expect(pushResult.find((i) => i.type === 'tags')).toHaveProperty('conflict', false);
});
});
});

View file

@ -9,9 +9,11 @@ import { readFile as fsReadFile } from 'node:fs/promises';
import path from 'path';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import { CredentialsService } from '@/credentials/credentials.service';
import type { Project } from '@/databases/entities/project';
import { SharedCredentials } from '@/databases/entities/shared-credentials';
import type { TagEntity } from '@/databases/entities/tag-entity';
import type { User } from '@/databases/entities/user';
import type { Variables } from '@/databases/entities/variables';
import type { WorkflowTagMapping } from '@/databases/entities/workflow-tag-mapping';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
@ -25,7 +27,9 @@ import { WorkflowTagMappingRepository } from '@/databases/repositories/workflow-
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
import type { IWorkflowToImport } from '@/interfaces';
import { isUniqueConstraintError } from '@/response-helper';
import { TagService } from '@/services/tag.service';
import { assertNever } from '@/utils';
import { WorkflowService } from '@/workflows/workflow.service';
import {
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
@ -62,6 +66,9 @@ export class SourceControlImportService {
private readonly variablesRepository: VariablesRepository,
private readonly workflowRepository: WorkflowRepository,
private readonly workflowTagMappingRepository: WorkflowTagMappingRepository,
private readonly workflowService: WorkflowService,
private readonly credentialsService: CredentialsService,
private readonly tagService: TagService,
instanceSettings: InstanceSettings,
) {
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
@ -500,6 +507,30 @@ export class SourceControlImportService {
return result;
}
async deleteWorkflowsNotInWorkfolder(user: User, candidates: SourceControlledFile[]) {
for (const candidate of candidates) {
await this.workflowService.delete(user, candidate.id);
}
}
async deleteCredentialsNotInWorkfolder(user: User, candidates: SourceControlledFile[]) {
for (const candidate of candidates) {
await this.credentialsService.delete(user, candidate.id);
}
}
async deleteVariablesNotInWorkfolder(candidates: SourceControlledFile[]) {
for (const candidate of candidates) {
await this.variablesService.delete(candidate.id);
}
}
async deleteTagsNotInWorkfolder(candidates: SourceControlledFile[]) {
for (const candidate of candidates) {
await this.tagService.delete(candidate.id);
}
}
private async findOrCreateOwnerProject(owner: ResourceOwner): Promise<Project | null> {
if (typeof owner === 'string' || owner.type === 'personal') {
const email = typeof owner === 'string' ? owner : owner.personalEmail;

View file

@ -191,7 +191,7 @@ export class SourceControlController {
@Body payload: PullWorkFolderRequestDto,
): Promise<SourceControlledFile[] | ImportResult | PullResult | undefined> {
try {
const result = await this.sourceControlService.pullWorkfolder(req.user.id, payload);
const result = await this.sourceControlService.pullWorkfolder(req.user, payload);
res.statusCode = result.statusCode;
return result.statusResult;
} catch (error) {

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