diff --git a/.github/workflows/test-workflows.yml b/.github/workflows/test-workflows.yml index 2bb91dd065..ec9de8dedc 100644 --- a/.github/workflows/test-workflows.yml +++ b/.github/workflows/test-workflows.yml @@ -74,6 +74,8 @@ jobs: N8N_ENCRYPTION_KEY: ${{secrets.ENCRYPTION_KEY}} SKIP_STATISTICS_EVENTS: true DB_SQLITE_POOL_SIZE: 4 + N8N_SENTRY_DSN: ${{secrets.CI_SENTRY_DSN}} + # - # name: Export credentials # if: always() @@ -93,7 +95,7 @@ jobs: - name: Notify Slack on failure uses: act10ns/slack@v2.0.0 - if: failure() + if: failure() && github.ref == 'refs/heads/master' with: status: ${{ job.status }} channel: '#alerts-build' diff --git a/CHANGELOG.md b/CHANGELOG.md index 32def33aa7..56003af56b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,43 @@ +# [1.70.0](https://github.com/n8n-io/n8n/compare/n8n@1.69.0...n8n@1.70.0) (2024-11-27) + + +### Bug Fixes + +* **AI Agent Node:** Add binary message before scratchpad to prevent tool calling loops ([#11845](https://github.com/n8n-io/n8n/issues/11845)) ([5c80cb5](https://github.com/n8n-io/n8n/commit/5c80cb57cf709a1097a38e0394aad6fce5330eba)) +* CodeNodeEditor walk cannot read properties of null ([#11129](https://github.com/n8n-io/n8n/issues/11129)) ([d99e0a7](https://github.com/n8n-io/n8n/commit/d99e0a7c979a1ee96b2eea1b9011d5bce375289a)) +* **core:** Bring back execution data on the `executionFinished` push message ([#11821](https://github.com/n8n-io/n8n/issues/11821)) ([0313570](https://github.com/n8n-io/n8n/commit/03135702f18e750ba44840dccfec042270629a2b)) +* **core:** Correct invalid WS status code on removing connection ([#11901](https://github.com/n8n-io/n8n/issues/11901)) ([1d80225](https://github.com/n8n-io/n8n/commit/1d80225d26ba01f78934a455acdcca7b83be7205)) +* **core:** Don't use unbound context methods in code sandboxes ([#11914](https://github.com/n8n-io/n8n/issues/11914)) ([f6c0d04](https://github.com/n8n-io/n8n/commit/f6c0d045e9683cd04ee849f37b96697097c5b41d)) +* **core:** Fix broken execution query when using projectId ([#11852](https://github.com/n8n-io/n8n/issues/11852)) ([a061dbc](https://github.com/n8n-io/n8n/commit/a061dbca07ad686c563e85c56081bc1a7830259b)) +* **core:** Fix validation of items returned in the task runner ([#11897](https://github.com/n8n-io/n8n/issues/11897)) ([a535e88](https://github.com/n8n-io/n8n/commit/a535e88f1aec8fbbf2eb9397d38748f49773de2d)) +* **editor:** Add missing trigger waiting tooltip on new canvas ([#11918](https://github.com/n8n-io/n8n/issues/11918)) ([a8df221](https://github.com/n8n-io/n8n/commit/a8df221bfbb5428d93d03f539bcfdaf29ee20c21)) +* **editor:** Don't re-render input panel after node finishes executing ([#11813](https://github.com/n8n-io/n8n/issues/11813)) ([b3a99a2](https://github.com/n8n-io/n8n/commit/b3a99a2351079c37ed6d83f43920ba80f3832234)) +* **editor:** Fix AI assistant loading message layout ([#11819](https://github.com/n8n-io/n8n/issues/11819)) ([89b4807](https://github.com/n8n-io/n8n/commit/89b48072432753137b498c338af7777036fdde7a)) +* **editor:** Fix new canvas discovery tooltip position after adding github stars button ([#11898](https://github.com/n8n-io/n8n/issues/11898)) ([f4ab5c7](https://github.com/n8n-io/n8n/commit/f4ab5c7b9244b8fdde427c12c1a152fbaaba0c34)) +* **editor:** Fix node position not getting set when dragging selection on new canvas ([#11871](https://github.com/n8n-io/n8n/issues/11871)) ([595de81](https://github.com/n8n-io/n8n/commit/595de81c03b3e488ab41fb8d1d316c3db6a8372a)) +* **editor:** Restore workers view ([#11876](https://github.com/n8n-io/n8n/issues/11876)) ([3aa72f6](https://github.com/n8n-io/n8n/commit/3aa72f613f64c16d7dff67ffe66037894e45aa7c)) +* **editor:** Turn NPS survey into a modal and make sure it shows above the Ask AI button ([#11814](https://github.com/n8n-io/n8n/issues/11814)) ([ca169f3](https://github.com/n8n-io/n8n/commit/ca169f3f3455fa39ce9120b30d7b409bade6561e)) +* **editor:** Use `crypto.randomUUID()` to initialize node id if missing on new canvas ([#11873](https://github.com/n8n-io/n8n/issues/11873)) ([bc4857a](https://github.com/n8n-io/n8n/commit/bc4857a1b3d6ea389f11fb8246a1cee33b8a008e)) +* **n8n Form Node:** Duplicate popup in manual mode ([#11925](https://github.com/n8n-io/n8n/issues/11925)) ([2c34bf4](https://github.com/n8n-io/n8n/commit/2c34bf4ea6137fb0fb321969684ffa621da20fa3)) +* **n8n Form Node:** Redirect if completion page to trigger ([#11822](https://github.com/n8n-io/n8n/issues/11822)) ([1a8fb7b](https://github.com/n8n-io/n8n/commit/1a8fb7bdc428c6a23c8708e2dcf924f1f10b47a9)) +* **OpenAI Node:** Remove preview chatInput parameter for `Assistant:Messsage` operation ([#11825](https://github.com/n8n-io/n8n/issues/11825)) ([4dde287](https://github.com/n8n-io/n8n/commit/4dde287cde3af7c9c0e57248e96b8f1270da9332)) +* Retain execution data between partial executions (new flow) ([#11828](https://github.com/n8n-io/n8n/issues/11828)) ([3320436](https://github.com/n8n-io/n8n/commit/3320436a6fdf8472b3843b9fe8d4de7af7f5ef5c)) + + +### Features + +* Add SharePoint credentials ([#11570](https://github.com/n8n-io/n8n/issues/11570)) ([05c6109](https://github.com/n8n-io/n8n/commit/05c61091db9bdd62fdcca910ead50d0bd512966a)) +* Add Zabbix credential only node ([#11489](https://github.com/n8n-io/n8n/issues/11489)) ([fbd1ecf](https://github.com/n8n-io/n8n/commit/fbd1ecfb29461fee393914bc200ec72c654d8944)) +* **AI Transform Node:** Support for drag and drop ([#11276](https://github.com/n8n-io/n8n/issues/11276)) ([2c252b0](https://github.com/n8n-io/n8n/commit/2c252b0b2d5282f4a87bce76f93c4c02dd8ff5e3)) +* **editor:** Drop `response` wrapper requirement from Subworkflow Tool output ([#11785](https://github.com/n8n-io/n8n/issues/11785)) ([cd3598a](https://github.com/n8n-io/n8n/commit/cd3598aaab6cefe58a4cb9df7d93fb501415e9d3)) +* **editor:** Improve node and edge bring-to-front mechanism on new canvas ([#11793](https://github.com/n8n-io/n8n/issues/11793)) ([b89ca9d](https://github.com/n8n-io/n8n/commit/b89ca9d482faa5cb542898f3973fb6e7c9a8437a)) +* **editor:** Make new canvas connections go underneath node when looping backwards ([#11833](https://github.com/n8n-io/n8n/issues/11833)) ([91d1bd8](https://github.com/n8n-io/n8n/commit/91d1bd8d333454f3971605df73c3703102d2a9e9)) +* **editor:** Make the left sidebar in Expressions editor draggable ([#11838](https://github.com/n8n-io/n8n/issues/11838)) ([a713b3e](https://github.com/n8n-io/n8n/commit/a713b3ed25feb1790412fc320cf41a0967635263)) +* **editor:** Migrate existing users to new canvas and set new canvas as default ([#11896](https://github.com/n8n-io/n8n/issues/11896)) ([caa7447](https://github.com/n8n-io/n8n/commit/caa744785a2cc5063a5fb9d269c0ea53ea432298)) +* **Slack Node:** Update wait for approval to use markdown ([#11754](https://github.com/n8n-io/n8n/issues/11754)) ([40dd02f](https://github.com/n8n-io/n8n/commit/40dd02f360d0d8752fe89c4304c18cac9858c530)) + + + # [1.69.0](https://github.com/n8n-io/n8n/compare/n8n@1.68.0...n8n@1.69.0) (2024-11-20) diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index bc5a18a34f..c414c9fea9 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -75,8 +75,13 @@ Cypress.Commands.add('signin', ({ email, password }) => { .then((response) => { Cypress.env('currentUserId', response.body.data.id); + // @TODO Remove this once the switcher is removed cy.window().then((win) => { - win.localStorage.setItem('NodeView.switcher.discovered', 'true'); // @TODO Remove this once the switcher is removed + win.localStorage.setItem('NodeView.migrated', 'true'); + win.localStorage.setItem('NodeView.switcher.discovered.beta', 'true'); + + const nodeViewVersion = Cypress.env('NODE_VIEW_VERSION'); + win.localStorage.setItem('NodeView.version', nodeViewVersion ?? '1'); }); }); }); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 4261cb4b63..0fe782499d 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -20,11 +20,6 @@ beforeEach(() => { win.localStorage.setItem('N8N_THEME', 'light'); win.localStorage.setItem('N8N_AUTOCOMPLETE_ONBOARDED', 'true'); win.localStorage.setItem('N8N_MAPPING_ONBOARDED', 'true'); - - const nodeViewVersion = Cypress.env('NODE_VIEW_VERSION'); - if (nodeViewVersion) { - win.localStorage.setItem('NodeView.version', nodeViewVersion); - } }); cy.intercept('GET', '/rest/settings', (req) => { diff --git a/docker/images/n8n-custom/Dockerfile b/docker/images/n8n-custom/Dockerfile index 797e78b3c6..210fc0630f 100644 --- a/docker/images/n8n-custom/Dockerfile +++ b/docker/images/n8n-custom/Dockerfile @@ -33,7 +33,7 @@ COPY docker/images/n8n/docker-entrypoint.sh / # Setup the Task Runner Launcher ARG TARGETPLATFORM -ARG LAUNCHER_VERSION=0.3.0-rc +ARG LAUNCHER_VERSION=0.6.0-rc COPY docker/images/n8n/n8n-task-runners.json /etc/n8n-task-runners.json # Download, verify, then extract the launcher binary RUN \ diff --git a/docker/images/n8n/Dockerfile b/docker/images/n8n/Dockerfile index 8acfc411cf..fe4aee41dc 100644 --- a/docker/images/n8n/Dockerfile +++ b/docker/images/n8n/Dockerfile @@ -24,7 +24,7 @@ RUN set -eux; \ # Setup the Task Runner Launcher ARG TARGETPLATFORM -ARG LAUNCHER_VERSION=0.3.0-rc +ARG LAUNCHER_VERSION=0.6.0-rc COPY n8n-task-runners.json /etc/n8n-task-runners.json # Download, verify, then extract the launcher binary RUN \ diff --git a/package.json b/package.json index 2098141852..3dc5f28b59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.69.0", + "version": "1.70.0", "private": true, "engines": { "node": ">=20.15", diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index 12399cb35c..5b6ce236e0 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/api-types", - "version": "0.7.0", + "version": "0.8.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 54b7956821..8f9c740ad6 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -172,4 +172,5 @@ export interface FrontendSettings { blockFileAccessToN8nFiles: boolean; }; betaFeatures: FrontendBetaFeatures[]; + virtualSchemaView: boolean; } diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index 0b923c3442..8bec6363b5 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/config", - "version": "1.19.0", + "version": "1.20.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/imap/package.json b/packages/@n8n/imap/package.json index 81643869a0..5a1d6b54f1 100644 --- a/packages/@n8n/imap/package.json +++ b/packages/@n8n/imap/package.json @@ -20,7 +20,7 @@ "dist/**/*" ], "dependencies": { - "iconv-lite": "0.6.3", + "iconv-lite": "catalog:", "imap": "0.8.19", "quoted-printable": "1.0.1", "utf8": "3.0.0", diff --git a/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts b/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts index 8708e0c7ea..d73d6d3268 100644 --- a/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/code/Code.node.ts @@ -81,31 +81,20 @@ function getSandbox( const workflowMode = this.getMode(); const context = getSandboxContext.call(this, itemIndex); - // eslint-disable-next-line @typescript-eslint/unbound-method - context.addInputData = this.addInputData; - // eslint-disable-next-line @typescript-eslint/unbound-method - context.addOutputData = this.addOutputData; - // eslint-disable-next-line @typescript-eslint/unbound-method - context.getInputConnectionData = this.getInputConnectionData; - // eslint-disable-next-line @typescript-eslint/unbound-method - context.getInputData = this.getInputData; - // eslint-disable-next-line @typescript-eslint/unbound-method - context.getNode = this.getNode; - // eslint-disable-next-line @typescript-eslint/unbound-method - context.getExecutionCancelSignal = this.getExecutionCancelSignal; - // eslint-disable-next-line @typescript-eslint/unbound-method - context.getNodeOutputs = this.getNodeOutputs; - // eslint-disable-next-line @typescript-eslint/unbound-method - context.executeWorkflow = this.executeWorkflow; - // eslint-disable-next-line @typescript-eslint/unbound-method - context.getWorkflowDataProxy = this.getWorkflowDataProxy; - // eslint-disable-next-line @typescript-eslint/unbound-method + context.addInputData = this.addInputData.bind(this); + context.addOutputData = this.addOutputData.bind(this); + context.getInputConnectionData = this.getInputConnectionData.bind(this); + context.getInputData = this.getInputData.bind(this); + context.getNode = this.getNode.bind(this); + context.getExecutionCancelSignal = this.getExecutionCancelSignal.bind(this); + context.getNodeOutputs = this.getNodeOutputs.bind(this); + context.executeWorkflow = this.executeWorkflow.bind(this); + context.getWorkflowDataProxy = this.getWorkflowDataProxy.bind(this); context.logger = this.logger; if (options?.addItems) { context.items = context.$input.all(); } - // eslint-disable-next-line @typescript-eslint/unbound-method const sandbox = new JavaScriptSandbox(context, code, this.helpers, { resolver: vmResolver, diff --git a/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentGithubLoader/DocumentGithubLoader.node.ts b/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentGithubLoader/DocumentGithubLoader.node.ts index 071134f25e..71a77f013c 100644 --- a/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentGithubLoader/DocumentGithubLoader.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentGithubLoader/DocumentGithubLoader.node.ts @@ -94,7 +94,7 @@ export class DocumentGithubLoader implements INodeType { }; async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { - console.log('Supplying data for Github Document Loader'); + this.logger.debug('Supplying data for Github Document Loader'); const repository = this.getNodeParameter('repository', itemIndex) as string; const branch = this.getNodeParameter('branch', itemIndex) as string; diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index e766da4273..da65509956 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "1.69.0", + "version": "1.70.0", "description": "", "main": "index.js", "scripts": { diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index 3fc222999d..285d56840f 100644 --- a/packages/@n8n/task-runner/package.json +++ b/packages/@n8n/task-runner/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/task-runner", - "version": "1.7.0", + "version": "1.8.0", "scripts": { "clean": "rimraf dist .turbo", "start": "node dist/start.js", diff --git a/packages/@n8n/task-runner/src/config/base-runner-config.ts b/packages/@n8n/task-runner/src/config/base-runner-config.ts index 2205145004..723d3279bf 100644 --- a/packages/@n8n/task-runner/src/config/base-runner-config.ts +++ b/packages/@n8n/task-runner/src/config/base-runner-config.ts @@ -9,7 +9,7 @@ class HealthcheckServerConfig { host: string = '127.0.0.1'; @Env('N8N_RUNNERS_SERVER_PORT') - port: number = 5680; + port: number = 5681; } @Config diff --git a/packages/@n8n/task-runner/src/js-task-runner/__tests__/result-validation.test.ts b/packages/@n8n/task-runner/src/js-task-runner/__tests__/result-validation.test.ts new file mode 100644 index 0000000000..7112468863 --- /dev/null +++ b/packages/@n8n/task-runner/src/js-task-runner/__tests__/result-validation.test.ts @@ -0,0 +1,110 @@ +import { ValidationError } from '@/js-task-runner/errors/validation-error'; +import { + validateRunForAllItemsOutput, + validateRunForEachItemOutput, +} from '@/js-task-runner/result-validation'; + +describe('result validation', () => { + describe('validateRunForAllItemsOutput', () => { + it('should throw an error if the output is not an object', () => { + expect(() => { + validateRunForAllItemsOutput(undefined); + }).toThrowError(ValidationError); + }); + + it('should throw an error if the output is an array and at least one item has a non-n8n key', () => { + expect(() => { + validateRunForAllItemsOutput([{ json: {} }, { json: {}, unknownKey: {} }]); + }).toThrowError(ValidationError); + }); + + it('should not throw an error if the output is an array and all items are json wrapped', () => { + expect(() => { + validateRunForAllItemsOutput([{ json: {} }, { json: {} }, { json: {} }]); + }).not.toThrow(); + }); + + test.each([ + ['binary', {}], + ['pairedItem', {}], + ['error', {}], + ])( + 'should not throw an error if the output item has %s key in addition to json', + (key, value) => { + expect(() => { + validateRunForAllItemsOutput([{ json: {} }, { json: {}, [key]: value }]); + }).not.toThrow(); + }, + ); + + it('should not throw an error if the output is an array and all items are not json wrapped', () => { + expect(() => { + validateRunForAllItemsOutput([ + { + id: 1, + name: 'test3', + }, + { + id: 2, + name: 'test4', + }, + { + id: 3, + name: 'test5', + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any); + }).not.toThrow(); + }); + + it('should throw if json is not an object', () => { + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + validateRunForAllItemsOutput([{ json: 1 } as any]); + }).toThrowError(ValidationError); + }); + }); + + describe('validateRunForEachItemOutput', () => { + const index = 0; + + it('should throw an error if the output is not an object', () => { + expect(() => { + validateRunForEachItemOutput(undefined, index); + }).toThrowError(ValidationError); + }); + + it('should throw an error if the output is an array', () => { + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + validateRunForEachItemOutput([] as any, index); + }).toThrowError(ValidationError); + }); + + it('should throw if json is not an object', () => { + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + validateRunForEachItemOutput({ json: 1 } as any, index); + }).toThrowError(ValidationError); + }); + + it('should throw an error if the output is an array and at least one item has a non-n8n key', () => { + expect(() => { + validateRunForEachItemOutput({ json: {}, unknownKey: {} }, index); + }).toThrowError(ValidationError); + }); + + test.each([ + ['binary', {}], + ['pairedItem', {}], + ['error', {}], + ])( + 'should not throw an error if the output item has %s key in addition to json', + (key, value) => { + expect(() => { + validateRunForEachItemOutput({ json: {}, [key]: value }, index); + }).not.toThrow(); + }, + ); + }); +}); diff --git a/packages/@n8n/task-runner/src/js-task-runner/result-validation.ts b/packages/@n8n/task-runner/src/js-task-runner/result-validation.ts index b7d0ffc5fc..4453521bf7 100644 --- a/packages/@n8n/task-runner/src/js-task-runner/result-validation.ts +++ b/packages/@n8n/task-runner/src/js-task-runner/result-validation.ts @@ -9,7 +9,7 @@ export const REQUIRED_N8N_ITEM_KEYS = new Set(['json', 'binary', 'pairedItem', ' function validateTopLevelKeys(item: INodeExecutionData, itemIndex: number) { for (const key in item) { if (Object.prototype.hasOwnProperty.call(item, key)) { - if (REQUIRED_N8N_ITEM_KEYS.has(key)) return; + if (REQUIRED_N8N_ITEM_KEYS.has(key)) continue; throw new ValidationError({ message: `Unknown top-level item key: ${key}`, diff --git a/packages/@n8n/task-runner/src/task-runner.ts b/packages/@n8n/task-runner/src/task-runner.ts index 8af8aeeb08..1486af280d 100644 --- a/packages/@n8n/task-runner/src/task-runner.ts +++ b/packages/@n8n/task-runner/src/task-runner.ts @@ -1,4 +1,4 @@ -import { ApplicationError, ensureError } from 'n8n-workflow'; +import { ApplicationError, ensureError, randomInt } from 'n8n-workflow'; import { nanoid } from 'nanoid'; import { EventEmitter } from 'node:events'; import { type MessageEvent, WebSocket } from 'ws'; @@ -42,8 +42,11 @@ export interface RPCCallObject { [name: string]: ((...args: unknown[]) => Promise) | RPCCallObject; } -const VALID_TIME_MS = 1000; -const VALID_EXTRA_MS = 100; +const OFFER_VALID_TIME_MS = 5000; +const OFFER_VALID_EXTRA_MS = 100; + +/** Converts milliseconds to nanoseconds */ +const msToNs = (ms: number) => BigInt(ms * 1_000_000); export interface TaskRunnerOpts extends BaseRunnerConfig { taskType: string; @@ -167,16 +170,20 @@ export abstract class TaskRunner extends EventEmitter { (Object.values(this.openOffers).length + Object.values(this.runningTasks).length); for (let i = 0; i < offersToSend; i++) { + // Add a bit of randomness so that not all offers expire at the same time + const validForInMs = OFFER_VALID_TIME_MS + randomInt(500); + // Add a little extra time to account for latency + const validUntil = process.hrtime.bigint() + msToNs(validForInMs + OFFER_VALID_EXTRA_MS); const offer: TaskOffer = { offerId: nanoid(), - validUntil: process.hrtime.bigint() + BigInt((VALID_TIME_MS + VALID_EXTRA_MS) * 1_000_000), // Adding a little extra time to account for latency + validUntil, }; this.openOffers.set(offer.offerId, offer); this.send({ type: 'runner:taskoffer', taskType: this.taskType, offerId: offer.offerId, - validFor: VALID_TIME_MS, + validFor: validForInMs, }); } } diff --git a/packages/cli/package.json b/packages/cli/package.json index ebd86d8a8f..18cd195620 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.69.0", + "version": "1.70.0", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/cli/src/commands/execute-batch.ts b/packages/cli/src/commands/execute-batch.ts index fbbecd2cbb..a70717c40b 100644 --- a/packages/cli/src/commands/execute-batch.ts +++ b/packages/cli/src/commands/execute-batch.ts @@ -4,7 +4,7 @@ import fs from 'fs'; import { diff } from 'json-diff'; import pick from 'lodash/pick'; import type { IRun, ITaskData, IWorkflowExecutionDataProcess } from 'n8n-workflow'; -import { ApplicationError, jsonParse } from 'n8n-workflow'; +import { ApplicationError, jsonParse, ErrorReporterProxy } from 'n8n-workflow'; import os from 'os'; import { sep } from 'path'; import { Container } from 'typedi'; @@ -822,6 +822,11 @@ export class ExecuteBatch extends BaseCommand { } } } catch (e) { + ErrorReporterProxy.error(e, { + extra: { + workflowId: workflowData.id, + }, + }); executionResult.error = `Workflow failed to execute: ${(e as Error).message}`; executionResult.executionStatus = 'error'; } diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 54fa07e7f5..1891d8193d 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -405,4 +405,11 @@ export const schema = { doc: 'Set this to 1 to enable the new partial execution logic by default.', }, }, + + virtualSchemaView: { + doc: 'Whether to display the virtualized schema view', + format: Boolean, + default: false, + env: 'N8N_VIRTUAL_SCHEMA_VIEW', + }, }; diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index 3f445e835a..e6a1dedb3f 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -22,6 +22,7 @@ import { SharedWorkflow } from './shared-workflow'; import { TagEntity } from './tag-entity'; import { TestDefinition } from './test-definition.ee'; import { TestMetric } from './test-metric.ee'; +import { TestRun } from './test-run.ee'; import { User } from './user'; import { Variables } from './variables'; import { WebhookEntity } from './webhook-entity'; @@ -62,4 +63,5 @@ export const entities = { ProcessedData, TestDefinition, TestMetric, + TestRun, }; diff --git a/packages/cli/src/databases/entities/test-run.ee.ts b/packages/cli/src/databases/entities/test-run.ee.ts new file mode 100644 index 0000000000..ab5f041f11 --- /dev/null +++ b/packages/cli/src/databases/entities/test-run.ee.ts @@ -0,0 +1,38 @@ +import { Column, Entity, Index, ManyToOne, RelationId } from '@n8n/typeorm'; + +import { + datetimeColumnType, + jsonColumnType, + WithTimestampsAndStringId, +} from '@/databases/entities/abstract-entity'; +import { TestDefinition } from '@/databases/entities/test-definition.ee'; + +type TestRunStatus = 'new' | 'running' | 'completed' | 'error'; + +export type AggregatedTestRunMetrics = Record; + +/** + * Entity representing a Test Run. + * It stores info about a specific run of a test, combining the test definition with the status and collected metrics + */ +@Entity() +@Index(['testDefinition']) +export class TestRun extends WithTimestampsAndStringId { + @ManyToOne('TestDefinition', 'runs') + testDefinition: TestDefinition; + + @RelationId((testRun: TestRun) => testRun.testDefinition) + testDefinitionId: string; + + @Column('varchar') + status: TestRunStatus; + + @Column({ type: datetimeColumnType, nullable: true }) + runAt: Date | null; + + @Column({ type: datetimeColumnType, nullable: true }) + completedAt: Date | null; + + @Column(jsonColumnType, { nullable: true }) + metrics: AggregatedTestRunMetrics; +} diff --git a/packages/cli/src/databases/migrations/common/1732549866705-CreateTestRunTable.ts b/packages/cli/src/databases/migrations/common/1732549866705-CreateTestRunTable.ts new file mode 100644 index 0000000000..e9dc0ba823 --- /dev/null +++ b/packages/cli/src/databases/migrations/common/1732549866705-CreateTestRunTable.ts @@ -0,0 +1,27 @@ +import type { MigrationContext, ReversibleMigration } from '@/databases/types'; + +const testRunTableName = 'test_run'; + +export class CreateTestRun1732549866705 implements ReversibleMigration { + async up({ schemaBuilder: { createTable, column } }: MigrationContext) { + await createTable(testRunTableName) + .withColumns( + column('id').varchar(36).primary.notNull, + column('testDefinitionId').varchar(36).notNull, + column('status').varchar().notNull, + column('runAt').timestamp(), + column('completedAt').timestamp(), + column('metrics').json, + ) + .withIndexOn('testDefinitionId') + .withForeignKey('testDefinitionId', { + tableName: 'test_definition', + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + } + + async down({ schemaBuilder: { dropTable } }: MigrationContext) { + await dropTable(testRunTableName); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index 534c4404fa..d962042333 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -72,6 +72,7 @@ import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/172 import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable'; import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition'; import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable'; +import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable'; export const mysqlMigrations: Migration[] = [ InitialMigration1588157391238, @@ -146,4 +147,5 @@ export const mysqlMigrations: Migration[] = [ AddDescriptionToTestDefinition1731404028106, MigrateTestDefinitionKeyToString1731582748663, CreateTestMetricTable1732271325258, + CreateTestRun1732549866705, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index 8a771490d9..012b18e31d 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -72,6 +72,7 @@ import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/172 import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable'; import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition'; import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable'; +import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable'; export const postgresMigrations: Migration[] = [ InitialMigration1587669153312, @@ -146,4 +147,5 @@ export const postgresMigrations: Migration[] = [ AddDescriptionToTestDefinition1731404028106, MigrateTestDefinitionKeyToString1731582748663, CreateTestMetricTable1732271325258, + CreateTestRun1732549866705, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index 80e5d8491d..7c8fcbf86f 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -69,6 +69,7 @@ import { SeparateExecutionCreationFromStart1727427440136 } from '../common/17274 import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/1729607673464-UpdateProcessedDataValueColumnToText'; import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable'; import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable'; +import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable'; const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, @@ -140,6 +141,7 @@ const sqliteMigrations: Migration[] = [ AddDescriptionToTestDefinition1731404028106, MigrateTestDefinitionKeyToString1731582748663, CreateTestMetricTable1732271325258, + CreateTestRun1732549866705, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/databases/repositories/test-run.repository.ee.ts b/packages/cli/src/databases/repositories/test-run.repository.ee.ts new file mode 100644 index 0000000000..e8e2a1a5ef --- /dev/null +++ b/packages/cli/src/databases/repositories/test-run.repository.ee.ts @@ -0,0 +1,29 @@ +import { DataSource, Repository } from '@n8n/typeorm'; +import { Service } from 'typedi'; + +import type { AggregatedTestRunMetrics } from '@/databases/entities/test-run.ee'; +import { TestRun } from '@/databases/entities/test-run.ee'; + +@Service() +export class TestRunRepository extends Repository { + constructor(dataSource: DataSource) { + super(TestRun, dataSource.manager); + } + + public async createTestRun(testDefinitionId: string) { + const testRun = this.create({ + status: 'new', + testDefinition: { id: testDefinitionId }, + }); + + return await this.save(testRun); + } + + public async markAsRunning(id: string) { + return await this.update(id, { status: 'running', runAt: new Date() }); + } + + public async markAsCompleted(id: string, metrics: AggregatedTestRunMetrics) { + return await this.update(id, { status: 'completed', completedAt: new Date(), metrics }); + } +} diff --git a/packages/cli/src/evaluation/metric.schema.ts b/packages/cli/src/evaluation/metric.schema.ts new file mode 100644 index 0000000000..fb186c50df --- /dev/null +++ b/packages/cli/src/evaluation/metric.schema.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const testMetricCreateRequestBodySchema = z + .object({ + name: z.string().min(1).max(255), + }) + .strict(); + +export const testMetricPatchRequestBodySchema = z + .object({ + name: z.string().min(1).max(255), + }) + .strict(); diff --git a/packages/cli/src/evaluation/metrics.controller.ts b/packages/cli/src/evaluation/metrics.controller.ts new file mode 100644 index 0000000000..af8f5c0408 --- /dev/null +++ b/packages/cli/src/evaluation/metrics.controller.ts @@ -0,0 +1,130 @@ +import express from 'express'; + +import { TestMetricRepository } from '@/databases/repositories/test-metric.repository.ee'; +import { Delete, Get, Patch, Post, RestController } from '@/decorators'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { + testMetricCreateRequestBodySchema, + testMetricPatchRequestBodySchema, +} from '@/evaluation/metric.schema'; +import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service'; + +import { TestDefinitionService } from './test-definition.service.ee'; +import { TestMetricsRequest } from './test-definitions.types.ee'; + +@RestController('/evaluation/test-definitions') +export class TestMetricsController { + constructor( + private readonly testDefinitionService: TestDefinitionService, + private readonly testMetricRepository: TestMetricRepository, + ) {} + + // This method is used in multiple places in the controller to get the test definition + // (or just check that it exists and the user has access to it). + private async getTestDefinition( + req: + | TestMetricsRequest.GetOne + | TestMetricsRequest.GetMany + | TestMetricsRequest.Patch + | TestMetricsRequest.Delete + | TestMetricsRequest.Create, + ) { + const { testDefinitionId } = req.params; + + const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']); + + const testDefinition = await this.testDefinitionService.findOne( + testDefinitionId, + userAccessibleWorkflowIds, + ); + + if (!testDefinition) throw new NotFoundError('Test definition not found'); + + return testDefinition; + } + + @Get('/:testDefinitionId/metrics') + async getMany(req: TestMetricsRequest.GetMany) { + const { testDefinitionId } = req.params; + + await this.getTestDefinition(req); + + return await this.testMetricRepository.find({ + where: { testDefinition: { id: testDefinitionId } }, + }); + } + + @Get('/:testDefinitionId/metrics/:id') + async getOne(req: TestMetricsRequest.GetOne) { + const { id: metricId, testDefinitionId } = req.params; + + await this.getTestDefinition(req); + + const metric = await this.testMetricRepository.findOne({ + where: { id: metricId, testDefinition: { id: testDefinitionId } }, + }); + + if (!metric) throw new NotFoundError('Metric not found'); + + return metric; + } + + @Post('/:testDefinitionId/metrics') + async create(req: TestMetricsRequest.Create, res: express.Response) { + const bodyParseResult = testMetricCreateRequestBodySchema.safeParse(req.body); + if (!bodyParseResult.success) { + res.status(400).json({ errors: bodyParseResult.error.errors }); + return; + } + + const testDefinition = await this.getTestDefinition(req); + + const metric = this.testMetricRepository.create({ + ...req.body, + testDefinition, + }); + + return await this.testMetricRepository.save(metric); + } + + @Patch('/:testDefinitionId/metrics/:id') + async patch(req: TestMetricsRequest.Patch, res: express.Response) { + const { id: metricId, testDefinitionId } = req.params; + + const bodyParseResult = testMetricPatchRequestBodySchema.safeParse(req.body); + if (!bodyParseResult.success) { + res.status(400).json({ errors: bodyParseResult.error.errors }); + return; + } + + await this.getTestDefinition(req); + + const metric = await this.testMetricRepository.findOne({ + where: { id: metricId, testDefinition: { id: testDefinitionId } }, + }); + + if (!metric) throw new NotFoundError('Metric not found'); + + await this.testMetricRepository.update(metricId, bodyParseResult.data); + + // Respond with the updated metric + return await this.testMetricRepository.findOneBy({ id: metricId }); + } + + @Delete('/:testDefinitionId/metrics/:id') + async delete(req: TestMetricsRequest.GetOne) { + const { id: metricId, testDefinitionId } = req.params; + + await this.getTestDefinition(req); + + const metric = await this.testMetricRepository.findOne({ + where: { id: metricId, testDefinition: { id: testDefinitionId } }, + }); + + if (!metric) throw new NotFoundError('Metric not found'); + + await this.testMetricRepository.delete(metricId); + + return { success: true }; + } +} diff --git a/packages/cli/src/evaluation/test-definitions.types.ee.ts b/packages/cli/src/evaluation/test-definitions.types.ee.ts index 2b784b10ef..939a37b949 100644 --- a/packages/cli/src/evaluation/test-definitions.types.ee.ts +++ b/packages/cli/src/evaluation/test-definitions.types.ee.ts @@ -33,3 +33,33 @@ export declare namespace TestDefinitionsRequest { type Run = AuthenticatedRequest; } + +// ---------------------------------- +// /test-definitions/:testDefinitionId/metrics +// ---------------------------------- + +export declare namespace TestMetricsRequest { + namespace RouteParams { + type TestDefinitionId = { + testDefinitionId: string; + }; + + type TestMetricId = { + id: string; + }; + } + + type GetOne = AuthenticatedRequest; + + type GetMany = AuthenticatedRequest; + + type Create = AuthenticatedRequest; + + type Patch = AuthenticatedRequest< + RouteParams.TestDefinitionId & RouteParams.TestMetricId, + {}, + { name: string } + >; + + type Delete = AuthenticatedRequest; +} diff --git a/packages/cli/src/evaluation/test-runner/__tests__/mock-data/workflow.evaluation.json b/packages/cli/src/evaluation/test-runner/__tests__/mock-data/workflow.evaluation.json index ceec74d809..c2386f010c 100644 --- a/packages/cli/src/evaluation/test-runner/__tests__/mock-data/workflow.evaluation.json +++ b/packages/cli/src/evaluation/test-runner/__tests__/mock-data/workflow.evaluation.json @@ -21,7 +21,7 @@ "conditions": [ { "id": "9d3abc8d-3270-4bec-9a59-82622d5dbb5a", - "leftValue": "={{ $json.actual.Code[0].data.main[0].length }}", + "leftValue": "={{ $json.newExecution.Code[0].data.main[0].length }}", "rightValue": 3, "operator": { "type": "number", @@ -30,7 +30,7 @@ }, { "id": "894ce84b-13a4-4415-99c0-0c25182903bb", - "leftValue": "={{ $json.actual.Code[0].data.main[0][0].json.random }}", + "leftValue": "={{ $json.newExecution.Code[0].data.main[0][0].json.random }}", "rightValue": 0.7, "operator": { "type": "number", diff --git a/packages/cli/src/evaluation/test-runner/__tests__/test-runner.service.ee.test.ts b/packages/cli/src/evaluation/test-runner/__tests__/test-runner.service.ee.test.ts index 1cd413370e..5c8f8e958b 100644 --- a/packages/cli/src/evaluation/test-runner/__tests__/test-runner.service.ee.test.ts +++ b/packages/cli/src/evaluation/test-runner/__tests__/test-runner.service.ee.test.ts @@ -2,13 +2,16 @@ import type { SelectQueryBuilder } from '@n8n/typeorm'; import { stringify } from 'flatted'; import { readFileSync } from 'fs'; import { mock, mockDeep } from 'jest-mock-extended'; +import type { IRun } from 'n8n-workflow'; import path from 'path'; import type { ActiveExecutions } from '@/active-executions'; import type { ExecutionEntity } from '@/databases/entities/execution-entity'; import type { TestDefinition } from '@/databases/entities/test-definition.ee'; +import type { TestRun } from '@/databases/entities/test-run.ee'; import type { User } from '@/databases/entities/user'; import type { ExecutionRepository } from '@/databases/repositories/execution.repository'; +import type { TestRunRepository } from '@/databases/repositories/test-run.repository.ee'; import type { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import type { WorkflowRunner } from '@/workflow-runner'; @@ -18,6 +21,10 @@ const wfUnderTestJson = JSON.parse( readFileSync(path.join(__dirname, './mock-data/workflow.under-test.json'), { encoding: 'utf-8' }), ); +const wfEvaluationJson = JSON.parse( + readFileSync(path.join(__dirname, './mock-data/workflow.evaluation.json'), { encoding: 'utf-8' }), +); + const executionDataJson = JSON.parse( readFileSync(path.join(__dirname, './mock-data/execution-data.json'), { encoding: 'utf-8' }), ); @@ -41,11 +48,22 @@ const executionMocks = [ }), ]; +function mockExecutionData() { + return mock({ + data: { + resultData: { + runData: {}, + }, + }, + }); +} + describe('TestRunnerService', () => { const executionRepository = mock(); const workflowRepository = mock(); const workflowRunner = mock(); const activeExecutions = mock(); + const testRunRepository = mock(); beforeEach(() => { const executionsQbMock = mockDeep>({ @@ -60,6 +78,16 @@ describe('TestRunnerService', () => { executionRepository.findOne .calledWith(expect.objectContaining({ where: { id: 'some-execution-id-2' } })) .mockResolvedValueOnce(executionMocks[1]); + + testRunRepository.createTestRun.mockResolvedValue(mock({ id: 'test-run-id' })); + }); + + afterEach(() => { + activeExecutions.getPostExecutePromise.mockClear(); + workflowRunner.run.mockClear(); + testRunRepository.createTestRun.mockClear(); + testRunRepository.markAsRunning.mockClear(); + testRunRepository.markAsCompleted.mockClear(); }); test('should create an instance of TestRunnerService', async () => { @@ -68,6 +96,7 @@ describe('TestRunnerService', () => { workflowRunner, executionRepository, activeExecutions, + testRunRepository, ); expect(testRunnerService).toBeInstanceOf(TestRunnerService); @@ -79,6 +108,7 @@ describe('TestRunnerService', () => { workflowRunner, executionRepository, activeExecutions, + testRunRepository, ); workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ @@ -86,12 +116,18 @@ describe('TestRunnerService', () => { ...wfUnderTestJson, }); + workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({ + id: 'evaluation-workflow-id', + ...wfEvaluationJson, + }); + workflowRunner.run.mockResolvedValue('test-execution-id'); await testRunnerService.runTest( mock(), mock({ workflowId: 'workflow-under-test-id', + evaluationWorkflowId: 'evaluation-workflow-id', }), ); @@ -99,4 +135,97 @@ describe('TestRunnerService', () => { expect(executionRepository.findOne).toHaveBeenCalledTimes(2); expect(workflowRunner.run).toHaveBeenCalledTimes(2); }); + + test('should run both workflow under test and evaluation workflow', async () => { + const testRunnerService = new TestRunnerService( + workflowRepository, + workflowRunner, + executionRepository, + activeExecutions, + testRunRepository, + ); + + workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ + id: 'workflow-under-test-id', + ...wfUnderTestJson, + }); + + workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({ + id: 'evaluation-workflow-id', + ...wfEvaluationJson, + }); + + workflowRunner.run.mockResolvedValueOnce('some-execution-id'); + workflowRunner.run.mockResolvedValueOnce('some-execution-id-2'); + workflowRunner.run.mockResolvedValueOnce('some-execution-id-3'); + workflowRunner.run.mockResolvedValueOnce('some-execution-id-4'); + + // Mock executions of workflow under test + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id') + .mockResolvedValue(mockExecutionData()); + + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id-2') + .mockResolvedValue(mockExecutionData()); + + // Mock executions of evaluation workflow + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id-3') + .mockResolvedValue(mockExecutionData()); + + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id-4') + .mockResolvedValue(mockExecutionData()); + + await testRunnerService.runTest( + mock(), + mock({ + workflowId: 'workflow-under-test-id', + evaluationWorkflowId: 'evaluation-workflow-id', + }), + ); + + expect(workflowRunner.run).toHaveBeenCalledTimes(4); + + // Check workflow under test was executed + expect(workflowRunner.run).toHaveBeenCalledWith( + expect.objectContaining({ + executionMode: 'evaluation', + pinData: { + 'When clicking ‘Test workflow’': + executionDataJson.resultData.runData['When clicking ‘Test workflow’'][0].data.main[0], + }, + workflowData: expect.objectContaining({ + id: 'workflow-under-test-id', + }), + }), + ); + + // Check evaluation workflow was executed + expect(workflowRunner.run).toHaveBeenCalledWith( + expect.objectContaining({ + executionMode: 'evaluation', + executionData: expect.objectContaining({ + executionData: expect.objectContaining({ + nodeExecutionStack: expect.arrayContaining([ + expect.objectContaining({ data: expect.anything() }), + ]), + }), + }), + workflowData: expect.objectContaining({ + id: 'evaluation-workflow-id', + }), + }), + ); + + // Check Test Run status was updated correctly + expect(testRunRepository.createTestRun).toHaveBeenCalledTimes(1); + expect(testRunRepository.markAsRunning).toHaveBeenCalledTimes(1); + expect(testRunRepository.markAsRunning).toHaveBeenCalledWith('test-run-id'); + expect(testRunRepository.markAsCompleted).toHaveBeenCalledTimes(1); + expect(testRunRepository.markAsCompleted).toHaveBeenCalledWith('test-run-id', { + success: false, + }); + }); }); diff --git a/packages/cli/src/evaluation/test-runner/test-runner.service.ee.ts b/packages/cli/src/evaluation/test-runner/test-runner.service.ee.ts index b57f89bea3..e717742b42 100644 --- a/packages/cli/src/evaluation/test-runner/test-runner.service.ee.ts +++ b/packages/cli/src/evaluation/test-runner/test-runner.service.ee.ts @@ -1,5 +1,11 @@ import { parse } from 'flatted'; -import type { IPinData, IRun, IWorkflowExecutionDataProcess } from 'n8n-workflow'; +import type { + IDataObject, + IPinData, + IRun, + IRunData, + IWorkflowExecutionDataProcess, +} from 'n8n-workflow'; import assert from 'node:assert'; import { Service } from 'typedi'; @@ -9,8 +15,10 @@ import type { TestDefinition } from '@/databases/entities/test-definition.ee'; import type { User } from '@/databases/entities/user'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; +import { TestRunRepository } from '@/databases/repositories/test-run.repository.ee'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import type { IExecutionResponse } from '@/interfaces'; +import { getRunData } from '@/workflow-execute-additional-data'; import { WorkflowRunner } from '@/workflow-runner'; /** @@ -18,7 +26,8 @@ import { WorkflowRunner } from '@/workflow-runner'; * It uses the test definitions to find * past executions, creates pin data from them, * and runs the workflow-under-test with the pin data. - * TODO: Evaluation workflows + * After the workflow-under-test finishes, it runs the evaluation workflow + * with the original and new run data. * TODO: Node pinning * TODO: Collect metrics */ @@ -29,17 +38,16 @@ export class TestRunnerService { private readonly workflowRunner: WorkflowRunner, private readonly executionRepository: ExecutionRepository, private readonly activeExecutions: ActiveExecutions, + private readonly testRunRepository: TestRunRepository, ) {} /** + * Extracts the execution data from the past execution. * Creates a pin data object from the past execution data * for the given workflow. * For now, it only pins trigger nodes. */ - private createPinDataFromExecution( - workflow: WorkflowEntity, - execution: ExecutionEntity, - ): IPinData { + private createTestDataFromExecution(workflow: WorkflowEntity, execution: ExecutionEntity) { const executionData = parse(execution.executionData.data) as IExecutionResponse['data']; const triggerNodes = workflow.nodes.filter((node) => /trigger$/i.test(node.type)); @@ -53,7 +61,7 @@ export class TestRunnerService { } } - return pinData; + return { pinData, executionData }; } /** @@ -65,6 +73,7 @@ export class TestRunnerService { testCasePinData: IPinData, userId: string, ): Promise { + // Prepare the data to run the workflow const data: IWorkflowExecutionDataProcess = { executionMode: 'evaluation', runData: {}, @@ -78,12 +87,55 @@ export class TestRunnerService { const executionId = await this.workflowRunner.run(data); assert(executionId); - // Wait for the workflow to finish execution + // Wait for the execution to finish const executePromise = this.activeExecutions.getPostExecutePromise(executionId); return await executePromise; } + /** + * Run the evaluation workflow with the expected and actual run data. + */ + private async runTestCaseEvaluation( + evaluationWorkflow: WorkflowEntity, + expectedData: IRunData, + actualData: IRunData, + ) { + // Prepare the evaluation wf input data. + // Provide both the expected data and the actual data + const evaluationInputData = { + json: { + originalExecution: expectedData, + newExecution: actualData, + }, + }; + + // Prepare the data to run the evaluation workflow + const data = await getRunData(evaluationWorkflow, [evaluationInputData]); + + data.executionMode = 'evaluation'; + + // Trigger the evaluation workflow + const executionId = await this.workflowRunner.run(data); + assert(executionId); + + // Wait for the execution to finish + const executePromise = this.activeExecutions.getPostExecutePromise(executionId); + + return await executePromise; + } + + private extractEvaluationResult(execution: IRun): IDataObject { + const lastNodeExecuted = execution.data.resultData.lastNodeExecuted; + assert(lastNodeExecuted, 'Could not find the last node executed in evaluation workflow'); + + // Extract the output of the last node executed in the evaluation workflow + // We use only the first item of a first main output + const lastNodeTaskData = execution.data.resultData.runData[lastNodeExecuted]?.[0]; + const mainConnectionData = lastNodeTaskData?.data?.main?.[0]; + return mainConnectionData?.[0]?.json ?? {}; + } + /** * Creates a new test run for the given test definition. */ @@ -91,6 +143,13 @@ export class TestRunnerService { const workflow = await this.workflowRepository.findById(test.workflowId); assert(workflow, 'Workflow not found'); + const evaluationWorkflow = await this.workflowRepository.findById(test.evaluationWorkflowId); + assert(evaluationWorkflow, 'Evaluation workflow not found'); + + // 0. Create new Test Run + const testRun = await this.testRunRepository.createTestRun(test.id); + assert(testRun, 'Unable to create a test run'); + // 1. Make test cases from previous executions // Select executions with the annotation tag and workflow ID of the test. @@ -105,25 +164,54 @@ export class TestRunnerService { .andWhere('execution.workflowId = :workflowId', { workflowId: test.workflowId }) .getMany(); - // 2. Run the test cases + // 2. Run over all the test cases + + await this.testRunRepository.markAsRunning(testRun.id); + + const metrics = []; for (const { id: pastExecutionId } of pastExecutions) { + // Fetch past execution with data const pastExecution = await this.executionRepository.findOne({ where: { id: pastExecutionId }, relations: ['executionData', 'metadata'], }); assert(pastExecution, 'Execution not found'); - const pinData = this.createPinDataFromExecution(workflow, pastExecution); + const testData = this.createTestDataFromExecution(workflow, pastExecution); + const { pinData, executionData } = testData; // Run the test case and wait for it to finish - const execution = await this.runTestCase(workflow, pinData, user.id); + const testCaseExecution = await this.runTestCase(workflow, pinData, user.id); - if (!execution) { + // In case of a permission check issue, the test case execution will be undefined. + // Skip them and continue with the next test case + if (!testCaseExecution) { continue; } - // TODO: 2.3 Collect the run data + // Collect the results of the test case execution + const testCaseRunData = testCaseExecution.data.resultData.runData; + + // Get the original runData from the test case execution data + const originalRunData = executionData.resultData.runData; + + // Run the evaluation workflow with the original and new run data + const evalExecution = await this.runTestCaseEvaluation( + evaluationWorkflow, + originalRunData, + testCaseRunData, + ); + assert(evalExecution); + + // Extract the output of the last node executed in the evaluation workflow + metrics.push(this.extractEvaluationResult(evalExecution)); } + + // TODO: 3. Aggregate the results + // Now we just set success to true if all the test cases passed + const aggregatedMetrics = { success: metrics.every((metric) => metric.success) }; + + await this.testRunRepository.markAsCompleted(testRun.id, aggregatedMetrics); } } diff --git a/packages/cli/src/runners/task-runner-server.ts b/packages/cli/src/runners/task-runner-server.ts index a3b13fb8c4..18a48cf39d 100644 --- a/packages/cli/src/runners/task-runner-server.ts +++ b/packages/cli/src/runners/task-runner-server.ts @@ -163,6 +163,8 @@ export class TaskRunnerServer { authEndpoint, send(async (req) => await this.taskRunnerAuthController.createGrantToken(req)), ); + + this.app.get('/healthz', (_, res) => res.send({ status: 'ok' })); } private handleUpgradeRequest = ( diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index e0572ab215..a21ad98ac2 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -64,6 +64,7 @@ import '@/executions/executions.controller'; import '@/external-secrets/external-secrets.controller.ee'; import '@/license/license.controller'; import '@/evaluation/test-definitions.controller.ee'; +import '@/evaluation/metrics.controller'; import '@/workflows/workflow-history/workflow-history.controller.ee'; import '@/workflows/workflows.controller'; diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 5fad80d83c..79d04b2263 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -231,6 +231,7 @@ export class FrontendService { blockFileAccessToN8nFiles: this.securityConfig.blockFileAccessToN8nFiles, }, betaFeatures: this.frontendConfig.betaFeatures, + virtualSchemaView: config.getEnv('virtualSchemaView'), }; } diff --git a/packages/cli/src/workflow-runner.ts b/packages/cli/src/workflow-runner.ts index 1dd646c1c1..6cade903d0 100644 --- a/packages/cli/src/workflow-runner.ts +++ b/packages/cli/src/workflow-runner.ts @@ -317,8 +317,9 @@ export class WorkflowRunner { workflowExecution = workflowExecute.runPartialWorkflow2( workflow, data.runData, - data.destinationNode, data.pinData, + data.dirtyNodeNames, + data.destinationNode, ); } else { workflowExecution = workflowExecute.runPartialWorkflow( diff --git a/packages/cli/src/workflows/workflow-execution.service.ts b/packages/cli/src/workflows/workflow-execution.service.ts index dd8480d759..d6ef8b4f93 100644 --- a/packages/cli/src/workflows/workflow-execution.service.ts +++ b/packages/cli/src/workflows/workflow-execution.service.ts @@ -89,7 +89,13 @@ export class WorkflowExecutionService { } async executeManually( - { workflowData, runData, startNodes, destinationNode }: WorkflowRequest.ManualRunPayload, + { + workflowData, + runData, + startNodes, + destinationNode, + dirtyNodeNames, + }: WorkflowRequest.ManualRunPayload, user: User, pushRef?: string, partialExecutionVersion?: string, @@ -137,6 +143,7 @@ export class WorkflowExecutionService { workflowData, userId: user.id, partialExecutionVersion: partialExecutionVersion ?? '0', + dirtyNodeNames, }; const hasRunData = (node: INode) => runData !== undefined && !!runData[node.name]; diff --git a/packages/cli/src/workflows/workflow.request.ts b/packages/cli/src/workflows/workflow.request.ts index d45cfd14d3..abfb58026a 100644 --- a/packages/cli/src/workflows/workflow.request.ts +++ b/packages/cli/src/workflows/workflow.request.ts @@ -22,6 +22,7 @@ export declare namespace WorkflowRequest { runData: IRunData; startNodes?: StartNodeData[]; destinationNode?: string; + dirtyNodeNames?: string[]; }; type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload>; diff --git a/packages/cli/templates/form-trigger-404.handlebars b/packages/cli/templates/form-trigger-404.handlebars index 234cb0e4dc..db6a9da13b 100644 --- a/packages/cli/templates/form-trigger-404.handlebars +++ b/packages/cli/templates/form-trigger-404.handlebars @@ -5,7 +5,7 @@ @@ -83,4 +83,4 @@ - \ No newline at end of file + diff --git a/packages/cli/templates/form-trigger-409.handlebars b/packages/cli/templates/form-trigger-409.handlebars index 6f34e5080d..95c14105d7 100644 --- a/packages/cli/templates/form-trigger-409.handlebars +++ b/packages/cli/templates/form-trigger-409.handlebars @@ -5,7 +5,7 @@ @@ -71,4 +71,4 @@ - \ No newline at end of file + diff --git a/packages/cli/templates/form-trigger-completion.handlebars b/packages/cli/templates/form-trigger-completion.handlebars index 761d09937b..49520fd14c 100644 --- a/packages/cli/templates/form-trigger-completion.handlebars +++ b/packages/cli/templates/form-trigger-completion.handlebars @@ -5,7 +5,7 @@ @@ -71,4 +71,4 @@ - \ No newline at end of file + diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index 02611f5b5b..8d2b13e5a7 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -4,7 +4,7 @@ diff --git a/packages/cli/templates/send-and-wait-no-action-required.handlebars b/packages/cli/templates/send-and-wait-no-action-required.handlebars index 7dcf99f10b..ac58775467 100644 --- a/packages/cli/templates/send-and-wait-no-action-required.handlebars +++ b/packages/cli/templates/send-and-wait-no-action-required.handlebars @@ -5,7 +5,7 @@ @@ -70,4 +70,4 @@ - \ No newline at end of file + diff --git a/packages/cli/test/integration/evaluation/metrics.api.test.ts b/packages/cli/test/integration/evaluation/metrics.api.test.ts new file mode 100644 index 0000000000..d10bb106a5 --- /dev/null +++ b/packages/cli/test/integration/evaluation/metrics.api.test.ts @@ -0,0 +1,381 @@ +import { Container } from 'typedi'; + +import type { TestDefinition } from '@/databases/entities/test-definition.ee'; +import type { User } from '@/databases/entities/user'; +import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; +import { TestDefinitionRepository } from '@/databases/repositories/test-definition.repository.ee'; +import { TestMetricRepository } from '@/databases/repositories/test-metric.repository.ee'; +import { createUserShell } from '@test-integration/db/users'; +import { createWorkflow } from '@test-integration/db/workflows'; +import * as testDb from '@test-integration/test-db'; +import type { SuperAgentTest } from '@test-integration/types'; +import * as utils from '@test-integration/utils'; + +let authOwnerAgent: SuperAgentTest; +let workflowUnderTest: WorkflowEntity; +let otherWorkflow: WorkflowEntity; +let testDefinition: TestDefinition; +let otherTestDefinition: TestDefinition; +let ownerShell: User; + +const testServer = utils.setupTestServer({ endpointGroups: ['evaluation'] }); + +beforeAll(async () => { + ownerShell = await createUserShell('global:owner'); + authOwnerAgent = testServer.authAgentFor(ownerShell); +}); + +beforeEach(async () => { + await testDb.truncate(['TestDefinition', 'TestMetric']); + + workflowUnderTest = await createWorkflow({ name: 'workflow-under-test' }, ownerShell); + + testDefinition = Container.get(TestDefinitionRepository).create({ + name: 'test', + workflow: { id: workflowUnderTest.id }, + }); + await Container.get(TestDefinitionRepository).save(testDefinition); + + otherWorkflow = await createWorkflow({ name: 'other-workflow' }); + + otherTestDefinition = Container.get(TestDefinitionRepository).create({ + name: 'other-test', + workflow: { id: otherWorkflow.id }, + }); + await Container.get(TestDefinitionRepository).save(otherTestDefinition); +}); + +describe('GET /evaluation/test-definitions/:testDefinitionId/metrics', () => { + test('should retrieve empty list of metrics for a test definition', async () => { + const resp = await authOwnerAgent.get( + `/evaluation/test-definitions/${testDefinition.id}/metrics`, + ); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data.length).toBe(0); + }); + + test('should retrieve metrics for a test definition', async () => { + const newMetric = Container.get(TestMetricRepository).create({ + testDefinition: { id: testDefinition.id }, + name: 'metric-1', + }); + await Container.get(TestMetricRepository).save(newMetric); + + const newMetric2 = Container.get(TestMetricRepository).create({ + testDefinition: { id: testDefinition.id }, + name: 'metric-2', + }); + await Container.get(TestMetricRepository).save(newMetric2); + + const resp = await authOwnerAgent.get( + `/evaluation/test-definitions/${testDefinition.id}/metrics`, + ); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data.length).toBe(2); + expect(resp.body.data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + name: 'metric-1', + }), + expect.objectContaining({ + id: expect.any(String), + name: 'metric-2', + }), + ]), + ); + }); + + test('should return 404 if test definition does not exist', async () => { + const resp = await authOwnerAgent.get('/evaluation/test-definitions/999/metrics'); + + expect(resp.statusCode).toBe(404); + }); + + test('should return 404 if test definition is not accessible to the user', async () => { + const resp = await authOwnerAgent.get( + `/evaluation/test-definitions/${otherTestDefinition.id}/metrics`, + ); + + expect(resp.statusCode).toBe(404); + }); +}); + +describe('GET /evaluation/test-definitions/:testDefinitionId/metrics/:id', () => { + test('should retrieve a metric for a test definition', async () => { + const newMetric = Container.get(TestMetricRepository).create({ + testDefinition: { id: testDefinition.id }, + name: 'metric-1', + }); + await Container.get(TestMetricRepository).save(newMetric); + + const resp = await authOwnerAgent.get( + `/evaluation/test-definitions/${testDefinition.id}/metrics/${newMetric.id}`, + ); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data).toEqual( + expect.objectContaining({ + id: newMetric.id, + name: 'metric-1', + }), + ); + }); + + test('should return 404 if metric does not exist', async () => { + const resp = await authOwnerAgent.get( + `/evaluation/test-definitions/${testDefinition.id}/metrics/999`, + ); + + expect(resp.statusCode).toBe(404); + }); + + test('should return 404 if metric is not accessible to the user', async () => { + const newMetric = Container.get(TestMetricRepository).create({ + testDefinition: { id: otherTestDefinition.id }, + name: 'metric-1', + }); + await Container.get(TestMetricRepository).save(newMetric); + + const resp = await authOwnerAgent.get( + `/evaluation/test-definitions/${otherTestDefinition.id}/metrics/${newMetric.id}`, + ); + + expect(resp.statusCode).toBe(404); + }); +}); + +describe('POST /evaluation/test-definitions/:testDefinitionId/metrics', () => { + test('should create a metric for a test definition', async () => { + const resp = await authOwnerAgent + .post(`/evaluation/test-definitions/${testDefinition.id}/metrics`) + .send({ + name: 'metric-1', + }); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data).toEqual( + expect.objectContaining({ + id: expect.any(String), + name: 'metric-1', + }), + ); + + const metrics = await Container.get(TestMetricRepository).find({ + where: { testDefinition: { id: testDefinition.id } }, + }); + expect(metrics.length).toBe(1); + expect(metrics[0].name).toBe('metric-1'); + }); + + test('should return 400 if name is missing', async () => { + const resp = await authOwnerAgent + .post(`/evaluation/test-definitions/${testDefinition.id}/metrics`) + .send({}); + + expect(resp.statusCode).toBe(400); + expect(resp.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'invalid_type', + message: 'Required', + path: ['name'], + }), + ]), + ); + }); + + test('should return 400 if name is not a string', async () => { + const resp = await authOwnerAgent + .post(`/evaluation/test-definitions/${testDefinition.id}/metrics`) + .send({ + name: 123, + }); + + expect(resp.statusCode).toBe(400); + expect(resp.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'invalid_type', + message: 'Expected string, received number', + path: ['name'], + }), + ]), + ); + }); + + test('should return 404 if test definition does not exist', async () => { + const resp = await authOwnerAgent.post('/evaluation/test-definitions/999/metrics').send({ + name: 'metric-1', + }); + + expect(resp.statusCode).toBe(404); + }); + + test('should return 404 if test definition is not accessible to the user', async () => { + const resp = await authOwnerAgent + .post(`/evaluation/test-definitions/${otherTestDefinition.id}/metrics`) + .send({ + name: 'metric-1', + }); + + expect(resp.statusCode).toBe(404); + }); +}); + +describe('PATCH /evaluation/test-definitions/:testDefinitionId/metrics/:id', () => { + test('should update a metric for a test definition', async () => { + const newMetric = Container.get(TestMetricRepository).create({ + testDefinition: { id: testDefinition.id }, + name: 'metric-1', + }); + await Container.get(TestMetricRepository).save(newMetric); + + const resp = await authOwnerAgent + .patch(`/evaluation/test-definitions/${testDefinition.id}/metrics/${newMetric.id}`) + .send({ + name: 'metric-2', + }); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data).toEqual( + expect.objectContaining({ + id: newMetric.id, + name: 'metric-2', + }), + ); + + const metrics = await Container.get(TestMetricRepository).find({ + where: { testDefinition: { id: testDefinition.id } }, + }); + expect(metrics.length).toBe(1); + expect(metrics[0].name).toBe('metric-2'); + }); + + test('should return 400 if name is missing', async () => { + const newMetric = Container.get(TestMetricRepository).create({ + testDefinition: { id: testDefinition.id }, + name: 'metric-1', + }); + await Container.get(TestMetricRepository).save(newMetric); + + const resp = await authOwnerAgent + .patch(`/evaluation/test-definitions/${testDefinition.id}/metrics/${newMetric.id}`) + .send({}); + + expect(resp.statusCode).toBe(400); + expect(resp.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'invalid_type', + message: 'Required', + path: ['name'], + }), + ]), + ); + }); + + test('should return 400 if name is not a string', async () => { + const newMetric = Container.get(TestMetricRepository).create({ + testDefinition: { id: testDefinition.id }, + name: 'metric-1', + }); + await Container.get(TestMetricRepository).save(newMetric); + + const resp = await authOwnerAgent + .patch(`/evaluation/test-definitions/${testDefinition.id}/metrics/${newMetric.id}`) + .send({ + name: 123, + }); + + expect(resp.statusCode).toBe(400); + expect(resp.body.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'invalid_type', + message: 'Expected string, received number', + }), + ]), + ); + }); + + test('should return 404 if metric does not exist', async () => { + const resp = await authOwnerAgent + .patch(`/evaluation/test-definitions/${testDefinition.id}/metrics/999`) + .send({ + name: 'metric-1', + }); + + expect(resp.statusCode).toBe(404); + }); + + test('should return 404 if test definition does not exist', async () => { + const resp = await authOwnerAgent.patch('/evaluation/test-definitions/999/metrics/999').send({ + name: 'metric-1', + }); + + expect(resp.statusCode).toBe(404); + }); + + test('should return 404 if metric is not accessible to the user', async () => { + const newMetric = Container.get(TestMetricRepository).create({ + testDefinition: { id: otherTestDefinition.id }, + name: 'metric-1', + }); + await Container.get(TestMetricRepository).save(newMetric); + + const resp = await authOwnerAgent + .patch(`/evaluation/test-definitions/${otherTestDefinition.id}/metrics/${newMetric.id}`) + .send({ + name: 'metric-2', + }); + + expect(resp.statusCode).toBe(404); + }); +}); + +describe('DELETE /evaluation/test-definitions/:testDefinitionId/metrics/:id', () => { + test('should delete a metric for a test definition', async () => { + const newMetric = Container.get(TestMetricRepository).create({ + testDefinition: { id: testDefinition.id }, + name: 'metric-1', + }); + await Container.get(TestMetricRepository).save(newMetric); + + const resp = await authOwnerAgent.delete( + `/evaluation/test-definitions/${testDefinition.id}/metrics/${newMetric.id}`, + ); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data).toEqual({ success: true }); + + const metrics = await Container.get(TestMetricRepository).find({ + where: { testDefinition: { id: testDefinition.id } }, + }); + expect(metrics.length).toBe(0); + }); + + test('should return 404 if metric does not exist', async () => { + const resp = await authOwnerAgent.delete( + `/evaluation/test-definitions/${testDefinition.id}/metrics/999`, + ); + + expect(resp.statusCode).toBe(404); + }); + + test('should return 404 if metric is not accessible to the user', async () => { + const newMetric = Container.get(TestMetricRepository).create({ + testDefinition: { id: otherTestDefinition.id }, + name: 'metric-1', + }); + await Container.get(TestMetricRepository).save(newMetric); + + const resp = await authOwnerAgent.delete( + `/evaluation/test-definitions/${otherTestDefinition.id}/metrics/${newMetric.id}`, + ); + + expect(resp.statusCode).toBe(404); + }); +}); diff --git a/packages/cli/test/integration/runners/task-runner-module.internal.test.ts b/packages/cli/test/integration/runners/task-runner-module.internal.test.ts index cb993f0a87..db195001a7 100644 --- a/packages/cli/test/integration/runners/task-runner-module.internal.test.ts +++ b/packages/cli/test/integration/runners/task-runner-module.internal.test.ts @@ -10,6 +10,7 @@ describe('TaskRunnerModule in internal mode', () => { const runnerConfig = Container.get(TaskRunnersConfig); runnerConfig.port = 0; // Random port runnerConfig.mode = 'internal'; + runnerConfig.enabled = true; const module = Container.get(TaskRunnerModule); afterEach(async () => { diff --git a/packages/cli/test/integration/runners/task-runner-process.test.ts b/packages/cli/test/integration/runners/task-runner-process.test.ts index df2fe8fc75..3b6c8aec8c 100644 --- a/packages/cli/test/integration/runners/task-runner-process.test.ts +++ b/packages/cli/test/integration/runners/task-runner-process.test.ts @@ -1,21 +1,15 @@ -import { TaskRunnersConfig } from '@n8n/config'; import Container from 'typedi'; import { TaskRunnerWsServer } from '@/runners/runner-ws-server'; import { TaskBroker } from '@/runners/task-broker.service'; import { TaskRunnerProcess } from '@/runners/task-runner-process'; -import { TaskRunnerServer } from '@/runners/task-runner-server'; import { retryUntil } from '@test-integration/retry-until'; +import { setupBrokerTestServer } from '@test-integration/utils/task-broker-test-server'; describe('TaskRunnerProcess', () => { - const authToken = 'token'; - const runnerConfig = Container.get(TaskRunnersConfig); - runnerConfig.enabled = true; - runnerConfig.mode = 'internal'; - runnerConfig.authToken = authToken; - runnerConfig.port = 0; // Use any port - const taskRunnerServer = Container.get(TaskRunnerServer); - + const { config, server: taskRunnerServer } = setupBrokerTestServer({ + mode: 'internal', + }); const runnerProcess = Container.get(TaskRunnerProcess); const taskBroker = Container.get(TaskBroker); const taskRunnerService = Container.get(TaskRunnerWsServer); @@ -23,7 +17,7 @@ describe('TaskRunnerProcess', () => { beforeAll(async () => { await taskRunnerServer.start(); // Set the port to the actually used port - runnerConfig.port = taskRunnerServer.port; + config.port = taskRunnerServer.port; }); afterAll(async () => { diff --git a/packages/cli/test/integration/runners/task-runner-server.test.ts b/packages/cli/test/integration/runners/task-runner-server.test.ts new file mode 100644 index 0000000000..11d77b53fc --- /dev/null +++ b/packages/cli/test/integration/runners/task-runner-server.test.ts @@ -0,0 +1,22 @@ +import { setupBrokerTestServer } from '@test-integration/utils/task-broker-test-server'; + +describe('TaskRunnerServer', () => { + const { agent, server } = setupBrokerTestServer({ + authToken: 'token', + mode: 'external', + }); + + beforeAll(async () => { + await server.start(); + }); + + afterAll(async () => { + await server.stop(); + }); + + describe('/healthz', () => { + it('should return 200', async () => { + await agent.get('/healthz').expect(200); + }); + }); +}); diff --git a/packages/cli/test/integration/shared/test-db.ts b/packages/cli/test/integration/shared/test-db.ts index e85825115e..5b70db3be2 100644 --- a/packages/cli/test/integration/shared/test-db.ts +++ b/packages/cli/test/integration/shared/test-db.ts @@ -75,6 +75,7 @@ const repositories = [ 'SharedWorkflow', 'Tag', 'TestDefinition', + 'TestMetric', 'User', 'Variables', 'Webhook', diff --git a/packages/cli/test/integration/shared/utils/task-broker-test-server.ts b/packages/cli/test/integration/shared/utils/task-broker-test-server.ts new file mode 100644 index 0000000000..9363fc089e --- /dev/null +++ b/packages/cli/test/integration/shared/utils/task-broker-test-server.ts @@ -0,0 +1,40 @@ +import { TaskRunnersConfig } from '@n8n/config'; +import request from 'supertest'; +import type TestAgent from 'supertest/lib/agent'; +import Container from 'typedi'; + +import { TaskRunnerServer } from '@/runners/task-runner-server'; + +export interface TestTaskBrokerServer { + server: TaskRunnerServer; + agent: TestAgent; + config: TaskRunnersConfig; +} + +/** + * Sets up a Task Broker Server for testing purposes. The server needs + * to be started and stopped manually. + * + * @example + * const { server, agent, config } = setupBrokerTestServer(); + * + * beforeAll(async () => await server.start()); + * afterAll(async () => await server.stop()); + */ +export const setupBrokerTestServer = ( + config: Partial = {}, +): TestTaskBrokerServer => { + const runnerConfig = Container.get(TaskRunnersConfig); + Object.assign(runnerConfig, config); + runnerConfig.enabled = true; + runnerConfig.port = 0; // Use any port + + const taskRunnerServer = Container.get(TaskRunnerServer); + const agent = request.agent(taskRunnerServer.app); + + return { + server: taskRunnerServer, + agent, + config: runnerConfig, + }; +}; diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index b1e7d8a93c..208e05e0f1 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -279,6 +279,7 @@ export const setupTestServer = ({ break; case 'evaluation': + await import('@/evaluation/metrics.controller'); await import('@/evaluation/test-definitions.controller.ee'); break; } diff --git a/packages/core/package.json b/packages/core/package.json index 25db4dec0c..cedac2f2f5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.69.0", + "version": "1.70.0", "description": "Core functionality of n8n", "main": "dist/index", "types": "dist/index.d.ts", @@ -47,6 +47,7 @@ "fast-glob": "catalog:", "file-type": "16.5.4", "form-data": "catalog:", + "iconv-lite": "catalog:", "lodash": "catalog:", "luxon": "catalog:", "mime-types": "2.1.35", diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 750ff80dd9..7ff1645335 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -28,6 +28,7 @@ import { createReadStream } from 'fs'; import { access as fsAccess, writeFile as fsWriteFile } from 'fs/promises'; import { IncomingMessage } from 'http'; import { Agent, type AgentOptions } from 'https'; +import iconv from 'iconv-lite'; import get from 'lodash/get'; import isEmpty from 'lodash/isEmpty'; import merge from 'lodash/merge'; @@ -674,14 +675,18 @@ function parseHeaderParameters(parameters: string[]): Record { return parameters.reduce( (acc, param) => { const [key, value] = param.split('='); - acc[key.toLowerCase().trim()] = decodeURIComponent(value); + let decodedValue = decodeURIComponent(value).trim(); + if (decodedValue.startsWith('"') && decodedValue.endsWith('"')) { + decodedValue = decodedValue.slice(1, -1); + } + acc[key.toLowerCase().trim()] = decodedValue; return acc; }, {} as Record, ); } -function parseContentType(contentType?: string): IContentType | null { +export function parseContentType(contentType?: string): IContentType | null { if (!contentType) { return null; } @@ -694,22 +699,7 @@ function parseContentType(contentType?: string): IContentType | null { }; } -function parseFileName(filename?: string): string | undefined { - if (filename?.startsWith('"') && filename?.endsWith('"')) { - return filename.slice(1, -1); - } - - return filename; -} - -// https://datatracker.ietf.org/doc/html/rfc5987 -function parseFileNameStar(filename?: string): string | undefined { - const [_encoding, _locale, content] = parseFileName(filename)?.split("'") ?? []; - - return content; -} - -function parseContentDisposition(contentDisposition?: string): IContentDisposition | null { +export function parseContentDisposition(contentDisposition?: string): IContentDisposition | null { if (!contentDisposition) { return null; } @@ -724,11 +714,15 @@ function parseContentDisposition(contentDisposition?: string): IContentDispositi const parsedParameters = parseHeaderParameters(parameters); - return { - type, - filename: - parseFileNameStar(parsedParameters['filename*']) ?? parseFileName(parsedParameters.filename), - }; + let { filename } = parsedParameters; + const wildcard = parsedParameters['filename*']; + if (wildcard) { + // https://datatracker.ietf.org/doc/html/rfc5987 + const [_encoding, _locale, content] = wildcard?.split("'") ?? []; + filename = content; + } + + return { type, filename }; } export function parseIncomingMessage(message: IncomingMessage) { @@ -745,13 +739,13 @@ export function parseIncomingMessage(message: IncomingMessage) { } } -export async function binaryToString(body: Buffer | Readable, encoding?: BufferEncoding) { - const buffer = await binaryToBuffer(body); +export async function binaryToString(body: Buffer | Readable, encoding?: string) { if (!encoding && body instanceof IncomingMessage) { parseIncomingMessage(body); encoding = body.encoding; } - return buffer.toString(encoding); + const buffer = await binaryToBuffer(body); + return iconv.decode(buffer, encoding ?? 'utf-8'); } export async function proxyRequestToAxios( diff --git a/packages/core/src/PartialExecutionUtils/__tests__/findStartNodes.test.ts b/packages/core/src/PartialExecutionUtils/__tests__/findStartNodes.test.ts index ab33ccf8ed..8dee8dff1b 100644 --- a/packages/core/src/PartialExecutionUtils/__tests__/findStartNodes.test.ts +++ b/packages/core/src/PartialExecutionUtils/__tests__/findStartNodes.test.ts @@ -46,7 +46,13 @@ describe('findStartNodes', () => { const node = createNodeData({ name: 'Basic Node' }); const graph = new DirectedGraph().addNode(node); - const startNodes = findStartNodes({ graph, trigger: node, destination: node }); + const startNodes = findStartNodes({ + graph, + trigger: node, + destination: node, + pinData: {}, + runData: {}, + }); expect(startNodes.size).toBe(1); expect(startNodes).toContainEqual(node); @@ -65,7 +71,13 @@ describe('findStartNodes', () => { // if the trigger has no run data { - const startNodes = findStartNodes({ graph, trigger, destination }); + const startNodes = findStartNodes({ + graph, + trigger, + destination, + pinData: {}, + runData: {}, + }); expect(startNodes.size).toBe(1); expect(startNodes).toContainEqual(trigger); @@ -77,7 +89,13 @@ describe('findStartNodes', () => { [trigger.name]: [toITaskData([{ data: { value: 1 } }])], }; - const startNodes = findStartNodes({ graph, trigger, destination, runData }); + const startNodes = findStartNodes({ + graph, + trigger, + destination, + runData, + pinData: {}, + }); expect(startNodes.size).toBe(1); expect(startNodes).toContainEqual(destination); @@ -112,7 +130,13 @@ describe('findStartNodes', () => { }; // ACT - const startNodes = findStartNodes({ graph, trigger, destination: node, runData }); + const startNodes = findStartNodes({ + graph, + trigger, + destination: node, + runData, + pinData: {}, + }); // ASSERT expect(startNodes.size).toBe(1); @@ -153,7 +177,13 @@ describe('findStartNodes', () => { { // ACT - const startNodes = findStartNodes({ graph, trigger, destination: node4 }); + const startNodes = findStartNodes({ + graph, + trigger, + destination: node4, + pinData: {}, + runData: {}, + }); // ASSERT expect(startNodes.size).toBe(1); @@ -172,7 +202,13 @@ describe('findStartNodes', () => { }; // ACT - const startNodes = findStartNodes({ graph, trigger, destination: node4, runData }); + const startNodes = findStartNodes({ + graph, + trigger, + destination: node4, + runData, + pinData: {}, + }); // ASSERT expect(startNodes.size).toBe(1); @@ -208,6 +244,7 @@ describe('findStartNodes', () => { runData: { [trigger.name]: [toITaskData([{ data: { value: 1 }, outputIndex: 0 }])], }, + pinData: {}, }); // ASSERT @@ -243,6 +280,7 @@ describe('findStartNodes', () => { runData: { [trigger.name]: [toITaskData([{ data: { value: 1 }, outputIndex: 1 }])], }, + pinData: {}, }); // ASSERT @@ -283,6 +321,7 @@ describe('findStartNodes', () => { ]), ], }, + pinData: {}, }); // ASSERT @@ -321,6 +360,7 @@ describe('findStartNodes', () => { [node1.name]: [toITaskData([{ data: { value: 1 }, outputIndex: 0 }])], [node2.name]: [toITaskData([{ data: { value: 1 }, outputIndex: 0 }])], }, + pinData: {}, }); // ASSERT @@ -357,6 +397,7 @@ describe('findStartNodes', () => { [trigger.name]: [toITaskData([{ data: { value: 1 } }])], [node1.name]: [toITaskData([{ data: { value: 1 }, outputIndex: 1 }])], }, + pinData: {}, }); // ASSERT @@ -389,7 +430,13 @@ describe('findStartNodes', () => { const pinData: IPinData = {}; // ACT - const startNodes = findStartNodes({ graph, trigger, destination: node2, runData, pinData }); + const startNodes = findStartNodes({ + graph, + trigger, + destination: node2, + runData, + pinData, + }); // ASSERT expect(startNodes.size).toBe(1); diff --git a/packages/core/src/PartialExecutionUtils/findStartNodes.ts b/packages/core/src/PartialExecutionUtils/findStartNodes.ts index 5eb036bd88..b3f4f95399 100644 --- a/packages/core/src/PartialExecutionUtils/findStartNodes.ts +++ b/packages/core/src/PartialExecutionUtils/findStartNodes.ts @@ -135,14 +135,14 @@ export function findStartNodes(options: { graph: DirectedGraph; trigger: INode; destination: INode; - runData?: IRunData; - pinData?: IPinData; + pinData: IPinData; + runData: IRunData; }): Set { const graph = options.graph; const trigger = options.trigger; const destination = options.destination; - const runData = options.runData ?? {}; - const pinData = options.pinData ?? {}; + const runData = { ...options.runData }; + const pinData = options.pinData; const startNodes = findStartNodesRecursive( graph, diff --git a/packages/core/src/WorkflowExecute.ts b/packages/core/src/WorkflowExecute.ts index 8eab139209..07819a7102 100644 --- a/packages/core/src/WorkflowExecute.ts +++ b/packages/core/src/WorkflowExecute.ts @@ -4,6 +4,7 @@ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ import * as assert from 'assert/strict'; import { setMaxListeners } from 'events'; +import { omit } from 'lodash'; import get from 'lodash/get'; import type { ExecutionBaseError, @@ -319,8 +320,9 @@ export class WorkflowExecute { runPartialWorkflow2( workflow: Workflow, runData: IRunData, + pinData: IPinData = {}, + dirtyNodeNames: string[] = [], destinationNodeName?: string, - pinData?: IPinData, ): PCancelable { // TODO: Refactor the call-site to make `destinationNodeName` a required // after removing the old partial execution flow. @@ -349,7 +351,8 @@ export class WorkflowExecute { const filteredNodes = subgraph.getNodes(); // 3. Find the Start Nodes - let startNodes = findStartNodes({ graph: subgraph, trigger, destination, runData }); + runData = omit(runData, dirtyNodeNames); + let startNodes = findStartNodes({ graph: subgraph, trigger, destination, runData, pinData }); // 4. Detect Cycles // 5. Handle Cycles diff --git a/packages/core/test/NodeExecuteFunctions.test.ts b/packages/core/test/NodeExecuteFunctions.test.ts index a796314b57..f754abec58 100644 --- a/packages/core/test/NodeExecuteFunctions.test.ts +++ b/packages/core/test/NodeExecuteFunctions.test.ts @@ -1,5 +1,5 @@ import { mkdtempSync, readFileSync } from 'fs'; -import type { IncomingMessage } from 'http'; +import { IncomingMessage } from 'http'; import type { Agent } from 'https'; import { mock } from 'jest-mock-extended'; import type { @@ -16,15 +16,19 @@ import type { import nock from 'nock'; import { tmpdir } from 'os'; import { join } from 'path'; +import { Readable } from 'stream'; import type { SecureContextOptions } from 'tls'; import Container from 'typedi'; import { BinaryDataService } from '@/BinaryData/BinaryData.service'; import { InstanceSettings } from '@/InstanceSettings'; import { + binaryToString, copyInputItems, getBinaryDataBuffer, isFilePathBlocked, + parseContentDisposition, + parseContentType, parseIncomingMessage, parseRequestObject, proxyRequestToAxios, @@ -148,6 +152,152 @@ describe('NodeExecuteFunctions', () => { }); }); + describe('parseContentType', () => { + const testCases = [ + { + input: 'text/plain', + expected: { + type: 'text/plain', + parameters: { + charset: 'utf-8', + }, + }, + description: 'should parse basic content type', + }, + { + input: 'TEXT/PLAIN', + expected: { + type: 'text/plain', + parameters: { + charset: 'utf-8', + }, + }, + description: 'should convert type to lowercase', + }, + { + input: 'text/html; charset=iso-8859-1', + expected: { + type: 'text/html', + parameters: { + charset: 'iso-8859-1', + }, + }, + description: 'should parse content type with charset', + }, + { + input: 'application/json; charset=utf-8; boundary=---123', + expected: { + type: 'application/json', + parameters: { + charset: 'utf-8', + boundary: '---123', + }, + }, + description: 'should parse content type with multiple parameters', + }, + { + input: 'text/plain; charset="utf-8"; filename="test.txt"', + expected: { + type: 'text/plain', + parameters: { + charset: 'utf-8', + filename: 'test.txt', + }, + }, + description: 'should handle quoted parameter values', + }, + { + input: 'text/plain; filename=%22test%20file.txt%22', + expected: { + type: 'text/plain', + parameters: { + charset: 'utf-8', + filename: 'test file.txt', + }, + }, + description: 'should handle encoded parameter values', + }, + { + input: undefined, + expected: null, + description: 'should return null for undefined input', + }, + { + input: '', + expected: null, + description: 'should return null for empty string', + }, + ]; + + test.each(testCases)('$description', ({ input, expected }) => { + expect(parseContentType(input)).toEqual(expected); + }); + }); + + describe('parseContentDisposition', () => { + const testCases = [ + { + input: 'attachment; filename="file.txt"', + expected: { type: 'attachment', filename: 'file.txt' }, + description: 'should parse basic content disposition', + }, + { + input: 'attachment; filename=file.txt', + expected: { type: 'attachment', filename: 'file.txt' }, + description: 'should parse filename without quotes', + }, + { + input: 'inline; filename="image.jpg"', + expected: { type: 'inline', filename: 'image.jpg' }, + description: 'should parse inline disposition', + }, + { + input: 'attachment; filename="my file.pdf"', + expected: { type: 'attachment', filename: 'my file.pdf' }, + description: 'should parse filename with spaces', + }, + { + input: "attachment; filename*=UTF-8''my%20file.txt", + expected: { type: 'attachment', filename: 'my file.txt' }, + description: 'should parse filename* parameter (RFC 5987)', + }, + { + input: 'filename="test.txt"', + expected: { type: 'attachment', filename: 'test.txt' }, + description: 'should handle invalid syntax but with filename', + }, + { + input: 'filename=test.txt', + expected: { type: 'attachment', filename: 'test.txt' }, + description: 'should handle invalid syntax with only filename parameter', + }, + { + input: undefined, + expected: null, + description: 'should return null for undefined input', + }, + { + input: '', + expected: null, + description: 'should return null for empty string', + }, + { + input: 'attachment; filename="%F0%9F%98%80.txt"', + expected: { type: 'attachment', filename: '😀.txt' }, + description: 'should handle encoded filenames', + }, + { + input: 'attachment; size=123; filename="test.txt"; creation-date="Thu, 1 Jan 2020"', + expected: { type: 'attachment', filename: 'test.txt' }, + description: 'should handle multiple parameters', + }, + ]; + + test.each(testCases)('$description', ({ input, expected }) => { + expect(parseContentDisposition(input)).toEqual(expected); + }); + }); + describe('parseIncomingMessage', () => { it('parses valid content-type header', () => { const message = mock({ @@ -168,6 +318,20 @@ describe('NodeExecuteFunctions', () => { parseIncomingMessage(message); expect(message.contentType).toEqual('application/json'); + expect(message.encoding).toEqual('utf-8'); + }); + + it('parses valid content-type header with encoding wrapped in quotes', () => { + const message = mock({ + headers: { + 'content-type': 'application/json; charset="utf-8"', + 'content-disposition': undefined, + }, + }); + parseIncomingMessage(message); + + expect(message.contentType).toEqual('application/json'); + expect(message.encoding).toEqual('utf-8'); }); it('parses valid content-disposition header with filename*', () => { @@ -549,6 +713,101 @@ describe('NodeExecuteFunctions', () => { }, ); }); + + describe('binaryToString', () => { + const ENCODING_SAMPLES = { + utf8: { + text: 'Hello, 世界! τεστ мир ⚡️ é à ü ñ', + buffer: Buffer.from([ + 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x2c, 0x20, 0xe4, 0xb8, 0x96, 0xe7, 0x95, 0x8c, 0x21, 0x20, + 0xcf, 0x84, 0xce, 0xb5, 0xcf, 0x83, 0xcf, 0x84, 0x20, 0xd0, 0xbc, 0xd0, 0xb8, 0xd1, 0x80, + 0x20, 0xe2, 0x9a, 0xa1, 0xef, 0xb8, 0x8f, 0x20, 0xc3, 0xa9, 0x20, 0xc3, 0xa0, 0x20, 0xc3, + 0xbc, 0x20, 0xc3, 0xb1, + ]), + }, + + 'iso-8859-15': { + text: 'Café € personnalité', + buffer: Buffer.from([ + 0x43, 0x61, 0x66, 0xe9, 0x20, 0xa4, 0x20, 0x70, 0x65, 0x72, 0x73, 0x6f, 0x6e, 0x6e, 0x61, + 0x6c, 0x69, 0x74, 0xe9, + ]), + }, + + latin1: { + text: 'señor année déjà', + buffer: Buffer.from([ + 0x73, 0x65, 0xf1, 0x6f, 0x72, 0x20, 0x61, 0x6e, 0x6e, 0xe9, 0x65, 0x20, 0x64, 0xe9, 0x6a, + 0xe0, + ]), + }, + + ascii: { + text: 'Hello, World! 123', + buffer: Buffer.from([ + 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x2c, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x21, 0x20, 0x31, + 0x32, 0x33, + ]), + }, + + 'windows-1252': { + text: '€ Smart "quotes" • bullet', + buffer: Buffer.from([ + 0x80, 0x20, 0x53, 0x6d, 0x61, 0x72, 0x74, 0x20, 0x22, 0x71, 0x75, 0x6f, 0x74, 0x65, 0x73, + 0x22, 0x20, 0x95, 0x20, 0x62, 0x75, 0x6c, 0x6c, 0x65, 0x74, + ]), + }, + + 'shift-jis': { + text: 'こんにちは世界', + buffer: Buffer.from([ + 0x82, 0xb1, 0x82, 0xf1, 0x82, 0xc9, 0x82, 0xbf, 0x82, 0xcd, 0x90, 0xa2, 0x8a, 0x45, + ]), + }, + + big5: { + text: '哈囉世界', + buffer: Buffer.from([0xab, 0xa2, 0xc5, 0x6f, 0xa5, 0x40, 0xac, 0xc9]), + }, + + 'koi8-r': { + text: 'Привет мир', + buffer: Buffer.from([0xf0, 0xd2, 0xc9, 0xd7, 0xc5, 0xd4, 0x20, 0xcd, 0xc9, 0xd2]), + }, + }; + + describe('should handle Buffer', () => { + for (const [encoding, { text, buffer }] of Object.entries(ENCODING_SAMPLES)) { + test(`with ${encoding}`, async () => { + const data = await binaryToString(buffer, encoding); + expect(data).toBe(text); + }); + } + }); + + describe('should handle streams', () => { + for (const [encoding, { text, buffer }] of Object.entries(ENCODING_SAMPLES)) { + test(`with ${encoding}`, async () => { + const stream = Readable.from(buffer); + const data = await binaryToString(stream, encoding); + expect(data).toBe(text); + }); + } + }); + + describe('should handle IncomingMessage', () => { + for (const [encoding, { text, buffer }] of Object.entries(ENCODING_SAMPLES)) { + test(`with ${encoding}`, async () => { + const response = Readable.from(buffer) as IncomingMessage; + response.headers = { 'content-type': `application/json;charset=${encoding}` }; + // @ts-expect-error need this hack to fake `instanceof IncomingMessage` checks + response.__proto__ = IncomingMessage.prototype; + const data = await binaryToString(response); + expect(data).toBe(text); + }); + } + }); + }); }); describe('isFilePathBlocked', () => { diff --git a/packages/core/test/WorkflowExecute.test.ts b/packages/core/test/WorkflowExecute.test.ts index 0cec3fa116..3c9b5cd96f 100644 --- a/packages/core/test/WorkflowExecute.test.ts +++ b/packages/core/test/WorkflowExecute.test.ts @@ -1,4 +1,4 @@ -import type { IRun, WorkflowTestData } from 'n8n-workflow'; +import type { IPinData, IRun, IRunData, WorkflowTestData } from 'n8n-workflow'; import { ApplicationError, createDeferredPromise, @@ -6,17 +6,20 @@ import { Workflow, } from 'n8n-workflow'; +import { DirectedGraph } from '@/PartialExecutionUtils'; +import { createNodeData, toITaskData } from '@/PartialExecutionUtils/__tests__/helpers'; import { WorkflowExecute } from '@/WorkflowExecute'; import * as Helpers from './helpers'; import { legacyWorkflowExecuteTests, v1WorkflowExecuteTests } from './helpers/constants'; +const nodeTypes = Helpers.NodeTypes(); + describe('WorkflowExecute', () => { describe('v0 execution order', () => { const tests: WorkflowTestData[] = legacyWorkflowExecuteTests; const executionMode = 'manual'; - const nodeTypes = Helpers.NodeTypes(); for (const testData of tests) { test(testData.description, async () => { @@ -217,4 +220,49 @@ describe('WorkflowExecute', () => { expect(nodeExecutionOutput[0][0].json.data).toEqual(123); expect(nodeExecutionOutput.getHints()[0].message).toEqual('TEXT HINT'); }); + + describe('runPartialWorkflow2', () => { + // Dirty ► + // ┌───────┐1 ┌─────┐1 ┌─────┐ + // │trigger├──────►node1├──────►node2│ + // └───────┘ └─────┘ └─────┘ + test("deletes dirty nodes' run data", async () => { + // ARRANGE + const waitPromise = createDeferredPromise(); + const nodeExecutionOrder: string[] = []; + const additionalData = Helpers.WorkflowExecuteAdditionalData(waitPromise, nodeExecutionOrder); + const workflowExecute = new WorkflowExecute(additionalData, 'manual'); + + const trigger = createNodeData({ name: 'trigger', type: 'n8n-nodes-base.manualTrigger' }); + const node1 = createNodeData({ name: 'node1' }); + const node2 = createNodeData({ name: 'node2' }); + const workflow = new DirectedGraph() + .addNodes(trigger, node1, node2) + .addConnections({ from: trigger, to: node1 }, { from: node1, to: node2 }) + .toWorkflow({ name: '', active: false, nodeTypes }); + const pinData: IPinData = {}; + const runData: IRunData = { + [trigger.name]: [toITaskData([{ data: { name: trigger.name } }])], + [node1.name]: [toITaskData([{ data: { name: node1.name } }])], + [node2.name]: [toITaskData([{ data: { name: node2.name } }])], + }; + const dirtyNodeNames = [node1.name]; + + jest.spyOn(workflowExecute, 'processRunExecutionData').mockImplementationOnce(jest.fn()); + + // ACT + await workflowExecute.runPartialWorkflow2( + workflow, + runData, + pinData, + dirtyNodeNames, + 'node2', + ); + + // ASSERT + const fullRunData = workflowExecute.getFullRunData(new Date()); + expect(fullRunData.data.resultData.runData).toHaveProperty(trigger.name); + expect(fullRunData.data.resultData.runData).not.toHaveProperty(node1.name); + }); + }); }); diff --git a/packages/core/test/helpers/constants.ts b/packages/core/test/helpers/constants.ts index f3de1c667c..3043f455e9 100644 --- a/packages/core/test/helpers/constants.ts +++ b/packages/core/test/helpers/constants.ts @@ -7,6 +7,7 @@ import type { import { NodeConnectionType } from 'n8n-workflow'; import { If } from '../../../nodes-base/dist/nodes/If/If.node'; +import { ManualTrigger } from '../../../nodes-base/dist/nodes/ManualTrigger/ManualTrigger.node'; import { Merge } from '../../../nodes-base/dist/nodes/Merge/Merge.node'; import { NoOp } from '../../../nodes-base/dist/nodes/NoOp/NoOp.node'; import { Set } from '../../../nodes-base/dist/nodes/Set/Set.node'; @@ -33,6 +34,10 @@ export const predefinedNodesTypes: INodeTypeData = { type: new Start(), sourcePath: '', }, + 'n8n-nodes-base.manualTrigger': { + type: new ManualTrigger(), + sourcePath: '', + }, 'n8n-nodes-base.versionTest': { sourcePath: '', type: { diff --git a/packages/design-system/package.json b/packages/design-system/package.json index af43f67103..a20208feb4 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,6 +1,6 @@ { "name": "n8n-design-system", - "version": "1.59.0", + "version": "1.60.0", "main": "src/main.ts", "import": "src/main.ts", "scripts": { diff --git a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue index ba905e75cf..1329d9a9ed 100644 --- a/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue +++ b/packages/design-system/src/components/N8nNavigationDropdown/NavigationDropdown.vue @@ -1,6 +1,6 @@ @@ -717,7 +715,7 @@ provide(CanvasKey, { :position="controlsPosition" :show-interactive="false" :show-bug-reporting-button="showBugReportingButton" - :zoom="zoom" + :zoom="viewport.zoom" @zoom-to-fit="onFitView" @zoom-in="onZoomIn" @zoom-out="onZoomOut" diff --git a/packages/editor-ui/src/components/canvas/elements/edges/CanvasConnectionLine.test.ts b/packages/editor-ui/src/components/canvas/elements/edges/CanvasConnectionLine.test.ts index 56ad3d6d89..8b99a45090 100644 --- a/packages/editor-ui/src/components/canvas/elements/edges/CanvasConnectionLine.test.ts +++ b/packages/editor-ui/src/components/canvas/elements/edges/CanvasConnectionLine.test.ts @@ -4,6 +4,7 @@ import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; import type { ConnectionLineProps } from '@vue-flow/core'; import { Position } from '@vue-flow/core'; +import { createCanvasProvide } from '@/__tests__/data'; const DEFAULT_PROPS = { sourceX: 0, @@ -15,6 +16,11 @@ const DEFAULT_PROPS = { } satisfies Partial; const renderComponent = createComponentRenderer(CanvasConnectionLine, { props: DEFAULT_PROPS, + global: { + provide: { + ...createCanvasProvide(), + }, + }, }); beforeEach(() => { diff --git a/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.vue b/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.vue index 10dcd6df4e..056878636e 100644 --- a/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.vue +++ b/packages/editor-ui/src/components/canvas/elements/edges/CanvasEdge.vue @@ -12,7 +12,7 @@ import { getEdgeRenderData } from './utils'; const emit = defineEmits<{ add: [connection: Connection]; delete: [connection: Connection]; - 'update:hovered': [hovered: boolean]; + 'update:label:hovered': [hovered: boolean]; }>(); export type CanvasEdgeProps = EdgeProps & { @@ -111,11 +111,11 @@ function onDelete() { } function onEdgeLabelMouseEnter() { - emit('update:hovered', true); + emit('update:label:hovered', true); } function onEdgeLabelMouseLeave() { - emit('update:hovered', false); + emit('update:label:hovered', false); } diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.test.ts b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.test.ts index 31818cc93a..6b72cdf037 100644 --- a/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.test.ts +++ b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.test.ts @@ -3,7 +3,7 @@ import { createComponentRenderer } from '@/__tests__/render'; import { createPinia, setActivePinia } from 'pinia'; import { NodeConnectionType } from 'n8n-workflow'; import { fireEvent } from '@testing-library/vue'; -import { createCanvasNodeProps } from '@/__tests__/data'; +import { createCanvasNodeProps, createCanvasProvide } from '@/__tests__/data'; vi.mock('@/stores/nodeTypes.store', () => ({ useNodeTypesStore: vi.fn(() => ({ @@ -19,7 +19,14 @@ beforeEach(() => { const pinia = createPinia(); setActivePinia(pinia); - renderComponent = createComponentRenderer(CanvasNode, { pinia }); + renderComponent = createComponentRenderer(CanvasNode, { + pinia, + global: { + provide: { + ...createCanvasProvide(), + }, + }, + }); }); describe('CanvasNode', () => { diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue index cfae68a724..e62046d5d7 100644 --- a/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue +++ b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNode.vue @@ -36,7 +36,6 @@ type Props = NodeProps & { readOnly?: boolean; eventBus?: EventBus; hovered?: boolean; - bringToFront?: boolean; // Determines if entire nodes layer should be brought to front }; const emit = defineEmits<{ @@ -81,7 +80,6 @@ const classes = computed(() => ({ [style.showToolbar]: showToolbar.value, hovered: props.hovered, selected: props.selected, - 'bring-to-front': props.bringToFront, })); /** diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeRenderer.test.ts b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeRenderer.test.ts index 28ccc20379..ae36476792 100644 --- a/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeRenderer.test.ts +++ b/packages/editor-ui/src/components/canvas/elements/nodes/CanvasNodeRenderer.test.ts @@ -1,6 +1,6 @@ import CanvasNodeRenderer from '@/components/canvas/elements/nodes/CanvasNodeRenderer.vue'; import { createComponentRenderer } from '@/__tests__/render'; -import { createCanvasNodeProvide } from '@/__tests__/data'; +import { createCanvasNodeProvide, createCanvasProvide } from '@/__tests__/data'; import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; import { CanvasNodeRenderType } from '@/types'; @@ -17,6 +17,7 @@ describe('CanvasNodeRenderer', () => { const { getByTestId } = renderComponent({ global: { provide: { + ...createCanvasProvide(), ...createCanvasNodeProvide(), }, }, @@ -29,6 +30,7 @@ describe('CanvasNodeRenderer', () => { const { getByTestId } = renderComponent({ global: { provide: { + ...createCanvasProvide(), ...createCanvasNodeProvide({ data: { render: { @@ -48,6 +50,7 @@ describe('CanvasNodeRenderer', () => { const { getByTestId } = renderComponent({ global: { provide: { + ...createCanvasProvide(), ...createCanvasNodeProvide({ data: { render: { diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.test.ts b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.test.ts index 422168c2ab..256720c879 100644 --- a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.test.ts +++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.test.ts @@ -1,12 +1,18 @@ import CanvasNodeDefault from '@/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue'; import { createComponentRenderer } from '@/__tests__/render'; import { NodeConnectionType } from 'n8n-workflow'; -import { createCanvasNodeProvide } from '@/__tests__/data'; +import { createCanvasNodeProvide, createCanvasProvide } from '@/__tests__/data'; import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; import { CanvasConnectionMode, CanvasNodeRenderType } from '@/types'; -const renderComponent = createComponentRenderer(CanvasNodeDefault); +const renderComponent = createComponentRenderer(CanvasNodeDefault, { + global: { + provide: { + ...createCanvasProvide(), + }, + }, +}); beforeEach(() => { const pinia = createTestingPinia(); diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue index 967a80e6b9..70a9005adc 100644 --- a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue +++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue @@ -1,13 +1,11 @@ + + + + diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeTriggerIcon.test.ts b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeTriggerIcon.test.ts new file mode 100644 index 0000000000..2fadcc0aee --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeTriggerIcon.test.ts @@ -0,0 +1,35 @@ +import CanvasNodeTriggerIcon from './CanvasNodeTriggerIcon.vue'; +import { createComponentRenderer } from '@/__tests__/render'; + +vi.mock('@/composables/useI18n', () => ({ + useI18n: vi.fn(() => ({ + baseText: vi.fn().mockReturnValue('This is a trigger node'), + })), +})); + +const renderComponent = createComponentRenderer(CanvasNodeTriggerIcon, { + global: { + stubs: { + FontAwesomeIcon: true, + }, + }, +}); + +describe('CanvasNodeTriggerIcon', () => { + it('should render trigger icon with tooltip', () => { + const { container } = renderComponent(); + + expect(container.querySelector('.triggerIcon')).toBeInTheDocument(); + + const icon = container.querySelector('font-awesome-icon-stub'); + expect(icon).toBeInTheDocument(); + expect(icon?.getAttribute('icon')).toBe('bolt'); + expect(icon?.getAttribute('size')).toBe('lg'); + }); + + it('should render tooltip with correct content', () => { + const { getByText } = renderComponent(); + + expect(getByText('This is a trigger node')).toBeInTheDocument(); + }); +}); diff --git a/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeTriggerIcon.vue b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeTriggerIcon.vue new file mode 100644 index 0000000000..e37f384dba --- /dev/null +++ b/packages/editor-ui/src/components/canvas/elements/nodes/render-types/parts/CanvasNodeTriggerIcon.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.test.ts b/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.test.ts new file mode 100644 index 0000000000..7e7c45b8d4 --- /dev/null +++ b/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.test.ts @@ -0,0 +1,73 @@ +import { createTestingPinia } from '@pinia/testing'; +import userEvent from '@testing-library/user-event'; +import { createComponentRenderer } from '@/__tests__/render'; +import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue'; + +vi.mock('vue-router', () => { + return { + useRouter: vi.fn(), + useRoute: vi.fn(), + RouterLink: { + template: '', + }, + }; +}); + +const renderComponent = createComponentRenderer(ConcurrentExecutionsHeader, { + pinia: createTestingPinia(), +}); + +describe('ConcurrentExecutionsHeader', () => { + it('should not throw error when rendered', async () => { + expect(() => + renderComponent({ + props: { + runningExecutionsCount: 0, + concurrencyCap: 0, + }, + }), + ).not.toThrow(); + }); + + test.each([ + [0, 5, 'No active executions'], + [2, 5, '2/5 active executions'], + ])( + 'shows the correct text when there are %i running executions of %i', + async (runningExecutionsCount, concurrencyCap, text) => { + const { getByText } = renderComponent({ + props: { + runningExecutionsCount, + concurrencyCap, + }, + }); + + expect(getByText(text)).toBeVisible(); + }, + ); + + it('should show tooltip on hover and call "goToUpgrade" on click', async () => { + const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + + const { container, getByText, getByRole, queryByRole } = renderComponent({ + props: { + runningExecutionsCount: 2, + concurrencyCap: 5, + }, + }); + + const tooltipTrigger = container.querySelector('svg') as SVGSVGElement; + + expect(tooltipTrigger).toBeVisible(); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + + await userEvent.hover(tooltipTrigger); + + expect(getByRole('tooltip')).toBeVisible(); + expect(getByText('Upgrade now')).toBeVisible(); + + await userEvent.click(getByText('Upgrade now')); + + expect(windowOpenSpy).toHaveBeenCalled(); + }); +}); diff --git a/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.vue b/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.vue new file mode 100644 index 0000000000..9ff4600546 --- /dev/null +++ b/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.test.ts b/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.test.ts index f1aa96ebaa..cefaee033a 100644 --- a/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.test.ts +++ b/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.test.ts @@ -6,9 +6,16 @@ import { faker } from '@faker-js/faker'; import { STORES, VIEWS } from '@/constants'; import ExecutionsList from '@/components/executions/global/GlobalExecutionsList.vue'; import { randomInt, type ExecutionSummary } from 'n8n-workflow'; -import { retry, SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/__tests__/utils'; +import type { MockedStore } from '@/__tests__/utils'; +import { + mockedStore, + retry, + SETTINGS_STORE_DEFAULT_STATE, + waitAllPromises, +} from '@/__tests__/utils'; import { createComponentRenderer } from '@/__tests__/render'; import { waitFor } from '@testing-library/vue'; +import { useSettingsStore } from '@/stores/settings.store'; vi.mock('vue-router', () => ({ useRoute: vi.fn().mockReturnValue({ @@ -18,7 +25,7 @@ vi.mock('vue-router', () => ({ RouterLink: vi.fn(), })); -let pinia: ReturnType; +let settingsStore: MockedStore; const generateUndefinedNullOrString = () => { switch (randomInt(4)) { @@ -58,6 +65,20 @@ const generateExecutionsData = () => })); const renderComponent = createComponentRenderer(ExecutionsList, { + pinia: createTestingPinia({ + initialState: { + [STORES.EXECUTIONS]: { + executions: [], + }, + [STORES.SETTINGS]: { + settings: merge(SETTINGS_STORE_DEFAULT_STATE.settings, { + enterprise: { + advancedExecutionFilters: true, + }, + }), + }, + }, + }), props: { autoRefreshEnabled: false, }, @@ -80,21 +101,7 @@ describe('GlobalExecutionsList', () => { beforeEach(() => { executionsData = generateExecutionsData(); - - pinia = createTestingPinia({ - initialState: { - [STORES.EXECUTIONS]: { - executions: [], - }, - [STORES.SETTINGS]: { - settings: merge(SETTINGS_STORE_DEFAULT_STATE.settings, { - enterprise: { - advancedExecutionFilters: true, - }, - }), - }, - }, - }); + settingsStore = mockedStore(useSettingsStore); }); it('should render empty list', async () => { @@ -105,7 +112,6 @@ describe('GlobalExecutionsList', () => { total: 0, estimated: false, }, - pinia, }); await waitAllPromises(); @@ -128,7 +134,6 @@ describe('GlobalExecutionsList', () => { filters: {}, estimated: false, }, - pinia, }); await waitAllPromises(); @@ -194,11 +199,22 @@ describe('GlobalExecutionsList', () => { filters: {}, estimated: false, }, - pinia, }); await waitAllPromises(); expect(queryAllByText(/Retry of/).length).toBe(retryOf.length); expect(queryAllByText(/Success retry/).length).toBe(retrySuccessId.length); }); + + it('should render concurrent executions header if the feature is enabled', async () => { + settingsStore.concurrency = 5; + const { getByTestId } = renderComponent({ + props: { + executions: executionsData[0].results, + filters: {}, + }, + }); + + expect(getByTestId('concurrent-executions-header')).toBeVisible(); + }); }); diff --git a/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue b/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue index 0a4dbecba3..38aeddda80 100644 --- a/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue +++ b/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue @@ -15,6 +15,7 @@ import type { PermissionsRecord } from '@/permissions'; import { getResourcePermissions } from '@/permissions'; import { useSettingsStore } from '@/stores/settings.store'; import ProjectHeader from '@/components/Projects/ProjectHeader.vue'; +import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue'; const props = withDefaults( defineProps<{ @@ -70,6 +71,10 @@ const isAnnotationEnabled = computed( () => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedExecutionFilters], ); +const runningExecutionsCount = computed(() => { + return props.executions.filter((execution) => execution.status === 'running').length; +}); + watch( () => props.executions, () => { @@ -320,6 +325,12 @@ async function onAutoRefreshToggle(value: boolean) {
+ (), { selected: false, @@ -42,6 +43,10 @@ const isRunning = computed(() => { return props.execution.status === 'running'; }); +const isQueued = computed(() => { + return props.execution.status === 'new'; +}); + const isWaitTillIndefinite = computed(() => { if (!props.execution.waitTill) { return false; @@ -80,6 +85,12 @@ const formattedStoppedAtDate = computed(() => { }); const statusTooltipText = computed(() => { + if (isQueued.value) { + return i18n.baseText('executionsList.statusTooltipText.waitingForConcurrencyCapacity', { + interpolate: { concurrencyCap: props.concurrencyCap }, + }); + } + if (props.execution.status === 'waiting' && isWaitTillIndefinite.value) { return i18n.baseText('executionsList.statusTooltipText.theWorkflowIsWaitingIndefinitely'); } @@ -178,7 +189,7 @@ async function handleActionItemClick(commandData: Command) { { + const location = {}; + return { + useRouter: vi.fn(), + useRoute: () => ({ + location, + }), + RouterLink: { + template: '', + }, + }; +}); + +const renderComponent = createComponentRenderer(WorkflowExecutionsSidebar, { + pinia: createTestingPinia({ + initialState: { + [STORES.EXECUTIONS]: { + executions: [], + }, + [STORES.SETTINGS]: { + settings: merge(SETTINGS_STORE_DEFAULT_STATE.settings, { + enterprise: { + advancedExecutionFilters: true, + }, + }), + }, + }, + }), +}); + +let settingsStore: MockedStore; + +describe('WorkflowExecutionsSidebar', () => { + beforeEach(() => { + settingsStore = mockedStore(useSettingsStore); + }); + + it('should not throw error when opened', async () => { + expect(() => + renderComponent({ + props: { + loading: false, + loadingMore: false, + executions: [], + }, + }), + ).not.toThrow(); + }); + + it('should render concurrent executions header if the feature is enabled', async () => { + settingsStore.concurrency = 5; + const { getByTestId } = renderComponent({ + props: { + loading: false, + loadingMore: false, + executions: [], + }, + }); + + expect(getByTestId('concurrent-executions-header')).toBeVisible(); + }); +}); diff --git a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsSidebar.vue b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsSidebar.vue index 6dc874d9b9..443a3d0f5d 100644 --- a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsSidebar.vue +++ b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsSidebar.vue @@ -13,6 +13,8 @@ import type { ExecutionFilterType, IWorkflowDb } from '@/Interface'; import { isComponentPublicInstance } from '@/utils/typeGuards'; import { getResourcePermissions } from '@/permissions'; import { useI18n } from '@/composables/useI18n'; +import { useSettingsStore } from '@/stores/settings.store'; +import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue'; type AutoScrollDeps = { activeExecutionSet: boolean; cardsMounted: boolean; scroll: boolean }; @@ -36,6 +38,7 @@ const router = useRouter(); const i18n = useI18n(); const executionsStore = useExecutionsStore(); +const settingsStore = useSettingsStore(); const mountedItems = ref([]); const autoScrollDeps = ref({ @@ -49,6 +52,10 @@ const executionListRef = ref(null); const workflowPermissions = computed(() => getResourcePermissions(props.workflow?.scopes).workflow); +const runningExecutionsCount = computed(() => { + return props.executions.filter((execution) => execution.status === 'running').length; +}); + watch( () => route, (to: RouteLocationNormalizedLoaded, from: RouteLocationNormalizedLoaded) => { @@ -174,6 +181,12 @@ function scrollToActiveCard(): void { {{ i18n.baseText('generic.executions') }} + +
canvas?.connectingHandle.value); - const isExecuting = computed(() => canvas?.isExecuting.value); - - return { - isExecuting, - connectingHandle, - }; + return injectStrict(CanvasKey); } diff --git a/packages/editor-ui/src/composables/useCanvasMapping.ts b/packages/editor-ui/src/composables/useCanvasMapping.ts index 98a32cfd12..036a2a2ad1 100644 --- a/packages/editor-ui/src/composables/useCanvasMapping.ts +++ b/packages/editor-ui/src/composables/useCanvasMapping.ts @@ -33,6 +33,7 @@ import type { ExecutionSummary, IConnections, INodeExecutionData, + INodeTypeDescription, ITaskData, Workflow, } from 'n8n-workflow'; @@ -48,6 +49,7 @@ import { import { sanitizeHtml } from '@/utils/htmlUtils'; import { MarkerType } from '@vue-flow/core'; import { useNodeHelpers } from './useNodeHelpers'; +import { getTriggerNodeServiceName } from '@/utils/nodeTypesUtils'; export function useCanvasMapping({ nodes, @@ -86,7 +88,7 @@ export function useCanvasMapping({ return { type: CanvasNodeRenderType.Default, options: { - trigger: nodeTypesStore.isTriggerNode(node.type), + trigger: isTriggerNodeById.value[node.id], configuration: nodeTypesStore.isConfigNode(workflowObject.value, node, node.type), configurable: nodeTypesStore.isConfigurableNode(workflowObject.value, node, node.type), inputs: { @@ -95,6 +97,7 @@ export function useCanvasMapping({ outputs: { labelSize: nodeOutputLabelSizeById.value[node.id], }, + tooltip: nodeTooltipById.value[node.id], }, }; } @@ -117,10 +120,34 @@ export function useCanvasMapping({ }, {}) ?? {}, ); + const nodeTypeDescriptionByNodeId = computed(() => + nodes.value.reduce>((acc, node) => { + acc[node.id] = nodeTypesStore.getNodeType(node.type, node.typeVersion); + return acc; + }, {}), + ); + + const isTriggerNodeById = computed(() => + nodes.value.reduce>((acc, node) => { + acc[node.id] = nodeTypesStore.isTriggerNode(node.type); + return acc; + }, {}), + ); + + const activeTriggerNodeCount = computed( + () => + nodes.value.filter( + (node) => + nodeTypeDescriptionByNodeId.value[node.id]?.eventTriggerDescription !== '' && + isTriggerNodeById.value[node.id] && + !node.disabled, + ).length, + ); + const nodeSubtitleById = computed(() => { return nodes.value.reduce>((acc, node) => { try { - const nodeTypeDescription = nodeTypesStore.getNodeType(node.type, node.typeVersion); + const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id]; if (!nodeTypeDescription) { return acc; } @@ -140,7 +167,7 @@ export function useCanvasMapping({ const nodeInputsById = computed(() => nodes.value.reduce>((acc, node) => { - const nodeTypeDescription = nodeTypesStore.getNodeType(node.type, node.typeVersion); + const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id]; const workflowObjectNode = workflowObject.value.getNode(node.name); acc[node.id] = @@ -203,7 +230,7 @@ export function useCanvasMapping({ const nodeOutputsById = computed(() => nodes.value.reduce>((acc, node) => { - const nodeTypeDescription = nodeTypesStore.getNodeType(node.type, node.typeVersion); + const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id]; const workflowObjectNode = workflowObject.value.getNode(node.name); acc[node.id] = @@ -229,6 +256,37 @@ export function useCanvasMapping({ }, {}), ); + const nodeTooltipById = computed(() => + nodes.value.reduce>((acc, node) => { + const nodeTypeDescription = nodeTypeDescriptionByNodeId.value[node.id]; + if (nodeTypeDescription && isTriggerNodeById.value[node.id]) { + if ( + activeTriggerNodeCount.value !== 1 || + !workflowsStore.isWorkflowRunning || + !['new', 'unknown', 'waiting'].includes(nodeExecutionStatusById.value[node.id]) + ) { + return acc; + } + + if ('eventTriggerDescription' in nodeTypeDescription) { + const nodeName = i18n.shortNodeType(nodeTypeDescription.name); + const { eventTriggerDescription } = nodeTypeDescription; + acc[node.id] = i18n + .nodeText() + .eventTriggerDescription(nodeName, eventTriggerDescription ?? ''); + } else { + acc[node.id] = i18n.baseText('node.waitingForYouToCreateAnEventIn', { + interpolate: { + nodeType: nodeTypeDescription ? getTriggerNodeServiceName(nodeTypeDescription) : '', + }, + }); + } + } + + return acc; + }, {}), + ); + const nodeExecutionRunningById = computed(() => nodes.value.reduce>((acc, node) => { acc[node.id] = workflowsStore.isNodeExecuting(node.name); diff --git a/packages/editor-ui/src/composables/useCanvasNode.ts b/packages/editor-ui/src/composables/useCanvasNode.ts index 6b051992fb..878ecc4d0f 100644 --- a/packages/editor-ui/src/composables/useCanvasNode.ts +++ b/packages/editor-ui/src/composables/useCanvasNode.ts @@ -59,7 +59,7 @@ export function useCanvasNode() { const executionStatus = computed(() => data.value.execution.status); const executionWaiting = computed(() => data.value.execution.waiting); const executionRunning = computed(() => data.value.execution.running); - const executionRunningThrottled = refThrottled(executionRunning, 300); + const executionRunningThrottled = refThrottled(executionRunning, 50); const runDataOutputMap = computed(() => data.value.runData.outputMap); const runDataIterations = computed(() => data.value.runData.iterations); diff --git a/packages/editor-ui/src/composables/useDataSchema.test.ts b/packages/editor-ui/src/composables/useDataSchema.test.ts index 3c685550c7..212f767675 100644 --- a/packages/editor-ui/src/composables/useDataSchema.test.ts +++ b/packages/editor-ui/src/composables/useDataSchema.test.ts @@ -1,5 +1,5 @@ import jp from 'jsonpath'; -import { useDataSchema } from '@/composables/useDataSchema'; +import { useDataSchema, useFlattenSchema } from '@/composables/useDataSchema'; import type { IExecutionResponse, INodeUi, Schema } from '@/Interface'; import { setActivePinia } from 'pinia'; import { createTestingPinia } from '@pinia/testing'; @@ -649,3 +649,39 @@ describe('useDataSchema', () => { ); }); }); + +describe('useFlattenSchema', () => { + it('flattens a schema', () => { + const schema: Schema = { + path: '', + type: 'object', + value: [ + { + key: 'obj', + path: '.obj', + type: 'object', + value: [ + { + key: 'foo', + path: '.obj.foo', + type: 'object', + value: [ + { + key: 'nested', + path: '.obj.foo.nested', + type: 'string', + value: 'bar', + }, + ], + }, + ], + }, + ], + }; + expect( + useFlattenSchema().flattenSchema({ + schema, + }).length, + ).toBe(3); + }); +}); diff --git a/packages/editor-ui/src/composables/useDataSchema.ts b/packages/editor-ui/src/composables/useDataSchema.ts index 8b20ab93fc..8896f8dd7e 100644 --- a/packages/editor-ui/src/composables/useDataSchema.ts +++ b/packages/editor-ui/src/composables/useDataSchema.ts @@ -1,15 +1,18 @@ +import { ref } from 'vue'; import type { Optional, Primitives, Schema, INodeUi } from '@/Interface'; import { type ITaskDataConnections, type IDataObject, type INodeExecutionData, + type INodeTypeDescription, NodeConnectionType, } from 'n8n-workflow'; import { merge } from 'lodash-es'; -import { generatePath } from '@/utils/mappingUtils'; +import { generatePath, getMappedExpression } from '@/utils/mappingUtils'; import { isObj } from '@/utils/typeGuards'; import { useWorkflowsStore } from '@/stores/workflows.store'; -import { isPresent } from '@/utils/typesUtils'; +import { isPresent, shorten } from '@/utils/typesUtils'; +import { useI18n } from '@/composables/useI18n'; export function useDataSchema() { function getSchema( @@ -166,3 +169,208 @@ export function useDataSchema() { filterSchema, }; } + +export type SchemaNode = { + node: INodeUi; + nodeType: INodeTypeDescription; + depth: number; + connectedOutputIndexes: number[]; + itemsCount: number; + schema: Schema; +}; + +export type RenderItem = { + title?: string; + path?: string; + level?: number; + depth?: number; + expression?: string; + value?: string; + id: string; + icon: string; + collapsable?: boolean; + nodeType?: INodeUi['type']; + type: 'item'; +}; + +export type RenderHeader = { + id: string; + title: string; + info?: string; + collapsable: boolean; + nodeType: INodeTypeDescription; + itemCount: number | null; + type: 'header'; +}; + +type Renders = RenderHeader | RenderItem; + +const icons = { + object: 'cube', + array: 'list', + ['string']: 'font', + null: 'font', + ['number']: 'hashtag', + ['boolean']: 'check-square', + function: 'code', + bigint: 'calculator', + symbol: 'sun', + ['undefined']: 'ban', +} as const; + +const getIconBySchemaType = (type: Schema['type']): string => icons[type]; + +const emptyItem = (): RenderItem => ({ + id: `empty-${window.crypto.randomUUID()}`, + icon: '', + value: useI18n().baseText('dataMapping.schemaView.emptyData'), + type: 'item', +}); + +const isDataEmpty = (schema: Schema) => { + // Utilize the generated schema instead of looping over the entire data again + // The schema for empty data is { type: 'object', value: [] } + const isObjectOrArray = schema.type === 'object'; + const isEmpty = Array.isArray(schema.value) && schema.value.length === 0; + + return isObjectOrArray && isEmpty; +}; + +const prefixTitle = (title: string, prefix?: string) => (prefix ? `${prefix}[${title}]` : title); + +export const useFlattenSchema = () => { + const closedNodes = ref>(new Set()); + const headerIds = ref>(new Set()); + const toggleLeaf = (id: string) => { + if (closedNodes.value.has(id)) { + closedNodes.value.delete(id); + } else { + closedNodes.value.add(id); + } + }; + + const toggleNode = (id: string) => { + if (closedNodes.value.has(id)) { + closedNodes.value = new Set(headerIds.value); + closedNodes.value.delete(id); + } else { + closedNodes.value.add(id); + } + }; + + const flattenSchema = ({ + schema, + node = { name: '', type: '' }, + depth = 0, + prefix = '', + level = 0, + }: { + schema: Schema; + node?: { name: string; type: string }; + depth?: number; + prefix?: string; + level?: number; + }): RenderItem[] => { + // only show empty item for the first level + if (isDataEmpty(schema) && depth <= 0) { + return [emptyItem()]; + } + + const expression = getMappedExpression({ + nodeName: node.name, + distanceFromActive: depth, + path: schema.path, + }); + + if (Array.isArray(schema.value)) { + const items: RenderItem[] = []; + + if (schema.key) { + items.push({ + title: prefixTitle(schema.key, prefix), + path: schema.path, + expression, + depth, + level, + icon: getIconBySchemaType(schema.type), + id: expression, + collapsable: true, + nodeType: node.type, + type: 'item', + }); + } + + if (closedNodes.value.has(expression)) { + return items; + } + + return items.concat( + schema.value + .map((item) => { + const itemPrefix = schema.type === 'array' ? schema.key : ''; + return flattenSchema({ + schema: item, + node, + depth, + prefix: itemPrefix, + level: level + 1, + }); + }) + .flat(), + ); + } else if (schema.key) { + return [ + { + title: prefixTitle(schema.key, prefix), + path: schema.path, + expression, + level, + depth, + value: shorten(schema.value, 600, 0), + id: expression, + icon: getIconBySchemaType(schema.type), + collapsable: false, + nodeType: node.type, + type: 'item', + }, + ]; + } + + return []; + }; + + const flattenMultipleSchemas = ( + nodes: SchemaNode[], + additionalInfo: (node: INodeUi) => string, + ) => { + headerIds.value.clear(); + + return nodes.reduce((acc, item) => { + acc.push({ + title: item.node.name, + id: item.node.name, + collapsable: true, + nodeType: item.nodeType, + itemCount: item.itemsCount, + info: additionalInfo(item.node), + type: 'header', + }); + + headerIds.value.add(item.node.name); + + if (closedNodes.value.has(item.node.name)) { + return acc; + } + + if (isDataEmpty(item.schema)) { + acc.push(emptyItem()); + return acc; + } + + acc.push(...flattenSchema(item)); + return acc; + }, []); + }; + + return { closedNodes, toggleLeaf, toggleNode, flattenSchema, flattenMultipleSchemas }; +}; diff --git a/packages/editor-ui/src/composables/useExpressionEditor.ts b/packages/editor-ui/src/composables/useExpressionEditor.ts index a63570d101..0911621be3 100644 --- a/packages/editor-ui/src/composables/useExpressionEditor.ts +++ b/packages/editor-ui/src/composables/useExpressionEditor.ts @@ -305,7 +305,11 @@ export const useExpressionEditor = ({ result.resolved = workflowHelpers.resolveExpression('=' + resolvable, undefined, opts); } } catch (error) { - result.resolved = `[${getExpressionErrorMessage(error)}]`; + const hasRunData = + !!workflowsStore.workflowExecutionData?.data?.resultData?.runData[ + ndvStore.activeNode?.name ?? '' + ]; + result.resolved = `[${getExpressionErrorMessage(error, hasRunData)}]`; result.error = true; result.fullError = error; } diff --git a/packages/editor-ui/src/composables/useNodeViewVersionSwitcher.test.ts b/packages/editor-ui/src/composables/useNodeViewVersionSwitcher.test.ts new file mode 100644 index 0000000000..e5cfe1cb2d --- /dev/null +++ b/packages/editor-ui/src/composables/useNodeViewVersionSwitcher.test.ts @@ -0,0 +1,156 @@ +import { useNodeViewVersionSwitcher } from './useNodeViewVersionSwitcher'; +import { useWorkflowsStore } from '@/stores/workflows.store'; +import { createTestingPinia } from '@pinia/testing'; +import { STORES } from '@/constants'; +import { setActivePinia } from 'pinia'; +import { mockedStore } from '@/__tests__/utils'; +import { useNDVStore } from '@/stores/ndv.store'; + +vi.mock('@/composables/useTelemetry', () => ({ + useTelemetry: () => ({ + track: vi.fn(), + }), +})); + +describe('useNodeViewVersionSwitcher', () => { + const initialState = { + [STORES.WORKFLOWS]: {}, + [STORES.NDV]: {}, + }; + + beforeEach(() => { + vi.clearAllMocks(); + const pinia = createTestingPinia({ initialState }); + setActivePinia(pinia); + }); + + describe('isNewUser', () => { + test('should return true when there are no active workflows', () => { + const { isNewUser } = useNodeViewVersionSwitcher(); + expect(isNewUser.value).toBe(true); + }); + + test('should return false when there are active workflows', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + workflowsStore.activeWorkflows = ['1']; + + const { isNewUser } = useNodeViewVersionSwitcher(); + expect(isNewUser.value).toBe(false); + }); + }); + + describe('nodeViewVersion', () => { + test('should initialize with default version "2"', () => { + const { nodeViewVersion } = useNodeViewVersionSwitcher(); + expect(nodeViewVersion.value).toBe('2'); + }); + }); + + describe('isNodeViewDiscoveryTooltipVisible', () => { + test('should be visible under correct conditions', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + workflowsStore.activeWorkflows = ['1']; + + const ndvStore = mockedStore(useNDVStore); + ndvStore.activeNodeName = null; + + const { isNodeViewDiscoveryTooltipVisible } = useNodeViewVersionSwitcher(); + expect(isNodeViewDiscoveryTooltipVisible.value).toBe(true); + }); + + test('should not be visible for new users', () => { + const workflowsStore = mockedStore(useWorkflowsStore); + workflowsStore.activeWorkflows = []; + + const { isNodeViewDiscoveryTooltipVisible } = useNodeViewVersionSwitcher(); + expect(isNodeViewDiscoveryTooltipVisible.value).toBe(false); + }); + + test('should not be visible when node is selected', () => { + const ndvStore = mockedStore(useNDVStore); + ndvStore.activeNodeName = 'test-node'; + + const { isNodeViewDiscoveryTooltipVisible } = useNodeViewVersionSwitcher(); + expect(isNodeViewDiscoveryTooltipVisible.value).toBe(false); + }); + }); + + describe('switchNodeViewVersion', () => { + test('should switch from version 2 to 1 and back', () => { + const { nodeViewVersion, switchNodeViewVersion } = useNodeViewVersionSwitcher(); + + switchNodeViewVersion(); + + expect(nodeViewVersion.value).toBe('1'); + + switchNodeViewVersion(); + + expect(nodeViewVersion.value).toBe('2'); + }); + }); + + describe('migrateToNewNodeViewVersion', () => { + test('should not migrate if already migrated', () => { + const { nodeViewVersion, nodeViewVersionMigrated, migrateToNewNodeViewVersion } = + useNodeViewVersionSwitcher(); + nodeViewVersionMigrated.value = true; + + migrateToNewNodeViewVersion(); + + expect(nodeViewVersion.value).toBe('2'); + }); + + test('should not migrate if already on version 2', () => { + const { nodeViewVersion, migrateToNewNodeViewVersion } = useNodeViewVersionSwitcher(); + nodeViewVersion.value = '2'; + + migrateToNewNodeViewVersion(); + + expect(nodeViewVersion.value).not.toBe('1'); + }); + + test('should migrate to version 2 if not migrated and on version 1', () => { + const { nodeViewVersion, nodeViewVersionMigrated, migrateToNewNodeViewVersion } = + useNodeViewVersionSwitcher(); + nodeViewVersion.value = '1'; + nodeViewVersionMigrated.value = false; + + migrateToNewNodeViewVersion(); + + expect(nodeViewVersion.value).toBe('2'); + expect(nodeViewVersionMigrated.value).toBe(true); + }); + }); + + describe('setNodeViewSwitcherDropdownOpened', () => { + test('should set discovered when dropdown is closed', () => { + const { setNodeViewSwitcherDropdownOpened, nodeViewSwitcherDiscovered } = + useNodeViewVersionSwitcher(); + + setNodeViewSwitcherDropdownOpened(false); + + expect(nodeViewSwitcherDiscovered.value).toBe(true); + nodeViewSwitcherDiscovered.value = false; + }); + + test('should not set discovered when dropdown is opened', () => { + const { setNodeViewSwitcherDropdownOpened, nodeViewSwitcherDiscovered } = + useNodeViewVersionSwitcher(); + + setNodeViewSwitcherDropdownOpened(true); + + expect(nodeViewSwitcherDiscovered.value).toBe(false); + }); + }); + + describe('setNodeViewSwitcherDiscovered', () => { + test('should set nodeViewSwitcherDiscovered to true', () => { + const { setNodeViewSwitcherDiscovered, nodeViewSwitcherDiscovered } = + useNodeViewVersionSwitcher(); + + setNodeViewSwitcherDiscovered(); + + expect(nodeViewSwitcherDiscovered.value).toBe(true); + }); + }); +}); diff --git a/packages/editor-ui/src/composables/useNodeViewVersionSwitcher.ts b/packages/editor-ui/src/composables/useNodeViewVersionSwitcher.ts index c91da5dcc2..ea89f8d533 100644 --- a/packages/editor-ui/src/composables/useNodeViewVersionSwitcher.ts +++ b/packages/editor-ui/src/composables/useNodeViewVersionSwitcher.ts @@ -1,6 +1,5 @@ import { computed } from 'vue'; -import { useLocalStorage, debouncedRef } from '@vueuse/core'; -import { useSettingsStore } from '@/stores/settings.store'; +import { useLocalStorage } from '@vueuse/core'; import { useTelemetry } from '@/composables/useTelemetry'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useNDVStore } from '@/stores/ndv.store'; @@ -8,16 +7,12 @@ import { useNDVStore } from '@/stores/ndv.store'; export function useNodeViewVersionSwitcher() { const ndvStore = useNDVStore(); const workflowsStore = useWorkflowsStore(); - const settingsStore = useSettingsStore(); const telemetry = useTelemetry(); const isNewUser = computed(() => workflowsStore.activeWorkflows.length === 0); - const isNewUserDebounced = debouncedRef(isNewUser, 3000); - const nodeViewVersion = useLocalStorage( - 'NodeView.version', - settingsStore.isCanvasV2Enabled ? '2' : '1', - ); + const nodeViewVersion = useLocalStorage('NodeView.version', '2'); + const nodeViewVersionMigrated = useLocalStorage('NodeView.migrated', false); function setNodeViewSwitcherDropdownOpened(visible: boolean) { if (!visible) { @@ -25,20 +20,21 @@ export function useNodeViewVersionSwitcher() { } } - const nodeViewSwitcherDiscovered = useLocalStorage('NodeView.switcher.discovered', false); + const nodeViewSwitcherDiscovered = useLocalStorage('NodeView.switcher.discovered.beta', false); function setNodeViewSwitcherDiscovered() { nodeViewSwitcherDiscovered.value = true; } const isNodeViewDiscoveryTooltipVisible = computed( () => + !isNewUser.value && !ndvStore.activeNodeName && - nodeViewVersion.value !== '2' && - !(isNewUserDebounced.value || nodeViewSwitcherDiscovered.value), + nodeViewVersion.value === '2' && + !nodeViewSwitcherDiscovered.value, ); function switchNodeViewVersion() { - const toVersion = nodeViewVersion.value === '1' ? '2' : '1'; + const toVersion = nodeViewVersion.value === '2' ? '1' : '2'; telemetry.track('User switched canvas version', { to_version: toVersion, @@ -47,12 +43,24 @@ export function useNodeViewVersionSwitcher() { nodeViewVersion.value = toVersion; } + function migrateToNewNodeViewVersion() { + if (nodeViewVersionMigrated.value || nodeViewVersion.value === '2') { + return; + } + + switchNodeViewVersion(); + nodeViewVersionMigrated.value = true; + } + return { + isNewUser, nodeViewVersion, + nodeViewVersionMigrated, nodeViewSwitcherDiscovered, isNodeViewDiscoveryTooltipVisible, setNodeViewSwitcherDropdownOpened, setNodeViewSwitcherDiscovered, switchNodeViewVersion, + migrateToNewNodeViewVersion, }; } diff --git a/packages/editor-ui/src/composables/useRunWorkflow.test.ts b/packages/editor-ui/src/composables/useRunWorkflow.test.ts index 4b50ebea4b..4a09e2e943 100644 --- a/packages/editor-ui/src/composables/useRunWorkflow.test.ts +++ b/packages/editor-ui/src/composables/useRunWorkflow.test.ts @@ -2,7 +2,13 @@ import { setActivePinia } from 'pinia'; import { createTestingPinia } from '@pinia/testing'; import { useRouter } from 'vue-router'; import type router from 'vue-router'; -import { ExpressionError, type IPinData, type IRunData, type Workflow } from 'n8n-workflow'; +import { + ExpressionError, + type IPinData, + type IRunData, + type Workflow, + type IExecuteData, +} from 'n8n-workflow'; import { useRootStore } from '@/stores/root.store'; import { useRunWorkflow } from '@/composables/useRunWorkflow'; @@ -28,6 +34,8 @@ vi.mock('@/stores/workflows.store', () => ({ getNodeByName: vi.fn(), getExecution: vi.fn(), nodeIssuesExit: vi.fn(), + checkIfNodeHasChatParent: vi.fn(), + getParametersLastUpdate: vi.fn(), }), })); @@ -69,6 +77,7 @@ vi.mock('@/composables/useWorkflowHelpers', () => ({ saveCurrentWorkflow: vi.fn(), getWorkflowDataToSave: vi.fn(), setDocumentTitle: vi.fn(), + executeData: vi.fn(), }), })); @@ -262,6 +271,60 @@ describe('useRunWorkflow({ router })', () => { expect(result).toEqual(mockExecutionResponse); }); + it('should send dirty nodes for partial executions', async () => { + vi.mocked(useLocalStorage).mockReturnValueOnce(ref(1)); + const composable = useRunWorkflow({ router }); + const parentName = 'When clicking'; + const executeName = 'Code'; + vi.mocked(workflowsStore).getWorkflowRunData = { + [parentName]: [ + { + startTime: 1, + executionTime: 0, + source: [], + }, + ], + [executeName]: [ + { + startTime: 1, + executionTime: 8, + source: [ + { + previousNode: parentName, + }, + ], + }, + ], + }; + vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue({ + name: 'Test Workflow', + getParentNodes: () => [parentName], + nodes: { [parentName]: {} }, + } as unknown as Workflow); + vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue({ + nodes: [], + } as unknown as IWorkflowData); + vi.mocked(workflowHelpers).executeData.mockResolvedValue({ + data: {}, + node: {}, + source: null, + } as IExecuteData); + + vi.mocked(workflowsStore).checkIfNodeHasChatParent.mockReturnValue(false); + vi.mocked(workflowsStore).getParametersLastUpdate.mockImplementation((name: string) => { + if (name === executeName) return 2; + return undefined; + }); + + const { runWorkflow } = composable; + + await runWorkflow({ destinationNode: 'Code 1', source: 'Node.executeNode' }); + + expect(workflowsStore.runWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ dirtyNodeNames: [executeName] }), + ); + }); + it('does not use the original run data if `PartialExecution.version` is set to 0', async () => { // ARRANGE const mockExecutionResponse = { executionId: '123' }; diff --git a/packages/editor-ui/src/composables/useRunWorkflow.ts b/packages/editor-ui/src/composables/useRunWorkflow.ts index 931f2df230..6b7dc9fda5 100644 --- a/packages/editor-ui/src/composables/useRunWorkflow.ts +++ b/packages/editor-ui/src/composables/useRunWorkflow.ts @@ -37,6 +37,25 @@ import { get } from 'lodash-es'; import { useExecutionsStore } from '@/stores/executions.store'; import { useLocalStorage } from '@vueuse/core'; +const getDirtyNodeNames = ( + runData: IRunData, + getParametersLastUpdate: (nodeName: string) => number | undefined, +): string[] | undefined => { + const dirtyNodeNames = Object.entries(runData).reduce((acc, [nodeName, tasks]) => { + if (!tasks.length) return acc; + + const updatedAt = getParametersLastUpdate(nodeName) ?? 0; + + if (updatedAt > tasks[0].startTime) { + acc.push(nodeName); + } + + return acc; + }, []); + + return dirtyNodeNames.length ? dirtyNodeNames : undefined; +}; + export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType }) { const nodeHelpers = useNodeHelpers(); const workflowHelpers = useWorkflowHelpers({ router: useRunWorkflowOpts.router }); @@ -244,6 +263,13 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType ({ + useToast: () => ({ showMessage }), +})); vi.mock('@/stores/users.store', () => ({ useUsersStore: vi.fn().mockReturnValue({ initialize: vi.fn() }), @@ -108,5 +115,21 @@ describe('Init', () => { expect(cloudStoreSpy).toHaveBeenCalledTimes(1); }); + + it('should handle source control initialization error', async () => { + vi.mocked(useUsersStore).mockReturnValue({ currentUser: { id: '123' } } as ReturnType< + typeof useUsersStore + >); + vi.spyOn(sourceControlStore, 'getPreferences').mockRejectedValueOnce( + new AxiosError('Something went wrong', '404'), + ); + const consoleSpy = vi.spyOn(window.console, 'error'); + await initializeAuthenticatedFeatures(false); + expect(showMessage).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to initialize source control store', + expect.anything(), + ); + }); }); }); diff --git a/packages/editor-ui/src/init.ts b/packages/editor-ui/src/init.ts index dfdcbe42d2..8b3a69f820 100644 --- a/packages/editor-ui/src/init.ts +++ b/packages/editor-ui/src/init.ts @@ -1,3 +1,4 @@ +import { h } from 'vue'; import { useCloudPlanStore } from '@/stores/cloudPlan.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useRootStore } from '@/stores/root.store'; @@ -8,6 +9,9 @@ import { useExternalHooks } from '@/composables/useExternalHooks'; import { useVersionsStore } from '@/stores/versions.store'; import { useProjectsStore } from '@/stores/projects.store'; import { useRolesStore } from './stores/roles.store'; +import { useToast } from '@/composables/useToast'; +import { useI18n } from '@/composables/useI18n'; +import SourceControlInitializationErrorMessage from '@/components/SourceControlInitializationErrorMessage.vue'; let coreInitialized = false; let authenticatedFeaturesInitialized = false; @@ -41,8 +45,10 @@ export async function initializeCore() { /** * Initializes the features of the application that require an authenticated user */ -export async function initializeAuthenticatedFeatures() { - if (authenticatedFeaturesInitialized) { +export async function initializeAuthenticatedFeatures( + initialized: boolean = authenticatedFeaturesInitialized, +) { + if (initialized) { return; } @@ -51,6 +57,8 @@ export async function initializeAuthenticatedFeatures() { return; } + const i18n = useI18n(); + const toast = useToast(); const sourceControlStore = useSourceControlStore(); const settingsStore = useSettingsStore(); const rootStore = useRootStore(); @@ -60,7 +68,17 @@ export async function initializeAuthenticatedFeatures() { const rolesStore = useRolesStore(); if (sourceControlStore.isEnterpriseSourceControlEnabled) { - await sourceControlStore.getPreferences(); + try { + await sourceControlStore.getPreferences(); + } catch (e) { + toast.showMessage({ + title: i18n.baseText('settings.sourceControl.connection.error'), + message: h(SourceControlInitializationErrorMessage), + type: 'error', + duration: 0, + }); + console.error('Failed to initialize source control store', e); + } } if (settingsStore.isTemplatesEnabled) { diff --git a/packages/editor-ui/src/n8n-theme.scss b/packages/editor-ui/src/n8n-theme.scss index 05064ca78a..33fa11cb5f 100644 --- a/packages/editor-ui/src/n8n-theme.scss +++ b/packages/editor-ui/src/n8n-theme.scss @@ -4,6 +4,7 @@ :root { // Using native css variable enables us to use this value in JS --header-height: 65; + --content-container-width: 1280px; } .clickable { diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index d0bc5e5a93..9712cee342 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -47,6 +47,7 @@ "generic.delete": "Delete", "generic.dontShowAgain": "Don't show again", "generic.executions": "Executions", + "generic.tests": "Tests", "generic.or": "or", "generic.clickToCopy": "Click to copy", "generic.copiedToClipboard": "Copied to clipboard", @@ -696,6 +697,9 @@ "executionsLandingPage.emptyState.accordion.footer.tooltipLink": "Save your workflow", "executionsLandingPage.emptyState.accordion.footer.tooltipText": "in order to access workflow settings", "executionsLandingPage.noResults": "No executions found", + "executionsList.activeExecutions.none": "No active executions", + "executionsList.activeExecutions.header": "{running}/{cap} active executions", + "executionsList.activeExecutions.tooltip": "Current active executions: {running} out of {cap} allowed by your plan. Upgrade to increase the limit.", "executionsList.allWorkflows": "All Workflows", "executionsList.anyStatus": "Any Status", "executionsList.autoRefresh": "Auto refresh", @@ -763,6 +767,8 @@ "executionsList.workflowExecutions": "Executions", "executionsList.view": "View", "executionsList.stop": "Stop", + "executionsList.statusTooltipText.waitingForWebhook": "The workflow is waiting indefinitely for an incoming webhook call.", + "executionsList.statusTooltipText.waitingForConcurrencyCapacity": "This execution will start once concurrency capacity is available. This instance is limited to {concurrencyCap} concurrent production executions.", "executionsList.statusTooltipText.theWorkflowIsWaitingIndefinitely": "The workflow is waiting indefinitely for an incoming webhook call.", "executionsList.debug.button.copyToEditor": "Copy to editor", "executionsList.debug.button.debugInEditor": "Debug in editor", @@ -833,6 +839,7 @@ "expressionModalInput.pairedItemConnectionError": "No path back to node", "expressionModalInput.pairedItemInvalidPinnedError": "Unpin node ‘{node}’ and execute", "expressionModalInput.pairedItemError": "Can’t determine which item to use", + "expressionModalInput.pairedItemError.noRunData": "Can't determine which item to use - execute node for more info", "fixedCollectionParameter.choose": "Choose...", "fixedCollectionParameter.currentlyNoItemsExist": "Currently no items exist", "fixedCollectionParameter.deleteItem": "Delete item", @@ -911,7 +918,9 @@ "menuActions.switchToOldNodeViewVersion": "Switch to old canvas", "menuActions.badge.new": "NEW", "menuActions.badge.alpha": "ALPHA", - "menuActions.nodeViewDiscovery.tooltip": "Try our new, more performant canvas", + "menuActions.badge.beta": "BETA", + "menuActions.nodeViewDiscovery.tooltip": "You're currently using our new, more performant canvas.", + "menuActions.nodeViewDiscovery.tooltip.switchBack": "You can switch back to the old version using this menu.", "multipleParameter.addItem": "Add item", "multipleParameter.currentlyNoItemsExist": "Currently no items exist", "multipleParameter.deleteItem": "Delete item", @@ -1872,6 +1881,9 @@ "settings.sourceControl.actionBox.description": "Use multiple instances for different environments (dev, prod, etc.), deploying between them via a Git repository.", "settings.sourceControl.actionBox.description.link": "More info", "settings.sourceControl.actionBox.buttonText": "See plans", + "settings.sourceControl.connection.error": "Source control failed to connect", + "settings.sourceControl.connection.error.message": "We couldn't find the repository connected to this instance. Please double-check your {link} on this instance.", + "settings.sourceControl.connection.error.link": "Git configuration", "settings.sourceControl.description": "Use multiple instances for different environments (dev, prod, etc.), deploying between them via a Git repository. {link}", "settings.sourceControl.description.link": "More info", "settings.sourceControl.gitConfig": "Git configuration", @@ -2718,5 +2730,47 @@ "communityPlusModal.button.skip": "Skip", "communityPlusModal.button.confirm": "Send me a free license key", "executeWorkflowTrigger.createNewSubworkflow": "Create a sub-workflow in {projectName}", - "executeWorkflowTrigger.createNewSubworkflow.noProject": "Create a new sub-workflow" + "executeWorkflowTrigger.createNewSubworkflow.noProject": "Create a new sub-workflow", + "testDefinition.edit.descriptionPlaceholder": "", + "testDefinition.edit.backButtonTitle": "Back to Workflow Evaluation", + "testDefinition.edit.namePlaceholder": "Enter test name", + "testDefinition.edit.metricsTitle": "Metrics", + "testDefinition.edit.metricsHelpText": "The output field of the last node in the evaluation workflow. Metrics will be averaged across all test cases.", + "testDefinition.edit.metricsFields": "Output field(s)", + "testDefinition.edit.metricsPlaceholder": "Enter metric name", + "testDefinition.edit.metricsNew": "New metric", + "testDefinition.edit.selectTag": "Select tag...", + "testDefinition.edit.tagsHelpText": "Executions with this tag will be added as test cases to this test.", + "testDefinition.edit.workflowSelectorLabel": "Workflow to make comparisons", + "testDefinition.edit.workflowSelectorDisplayName": "Workflow", + "testDefinition.edit.workflowSelectorTitle": "Workflow to make comparisons", + "testDefinition.edit.workflowSelectorHelpText": "This workflow will be called once for each test case.", + "testDefinition.edit.updateTest": "Update test", + "testDefinition.edit.saveTest": "Run test", + "testDefinition.edit.testSaved": "Test saved", + "testDefinition.edit.testSaveFailed": "Failed to save test", + "testDefinition.edit.description": "Description", + "testDefinition.edit.tagName": "Tag name", + "testDefinition.edit.step.intro": "When running a test", + "testDefinition.edit.step.executions": "Fetch 5 past executions", + "testDefinition.edit.step.nodes": "Mock nodes", + "testDefinition.edit.step.mockedNodes": "No nodes mocked | {count} node mocked | {count} nodes mocked", + "testDefinition.edit.step.reRunExecutions": "Re-run executions", + "testDefinition.edit.step.compareExecutions": "Compare each past and new execution", + "testDefinition.edit.step.metrics": "Summarise metrics", + "testDefinition.edit.step.collapse": "Collapse", + "testDefinition.edit.step.expand": "Expand", + "testDefinition.list.testDeleted": "Test deleted", + "testDefinition.list.tests": "Tests", + "testDefinition.list.createNew": "Create new test", + "testDefinition.list.actionDescription": "Replay past executions to check whether performance has changed", + "testDefinition.list.actionButton": "Create Test", + "testDefinition.list.testCases": "No test cases | {count} test case | {count} test cases", + "testDefinition.list.lastRun": "Ran {lastRun}", + "testDefinition.list.errorRate": "Error rate: {errorRate}", + "testDefinition.runTest": "Run Test", + "testDefinition.notImplemented": "This feature is not implemented yet!", + "testDefinition.viewDetails": "View Details", + "testDefinition.editTest": "Edit Test", + "testDefinition.deleteTest": "Delete Test" } diff --git a/packages/editor-ui/src/plugins/icons/index.ts b/packages/editor-ui/src/plugins/icons/index.ts index 0e9430d8ae..561ad6afbf 100644 --- a/packages/editor-ui/src/plugins/icons/index.ts +++ b/packages/editor-ui/src/plugins/icons/index.ts @@ -51,6 +51,7 @@ import { faEllipsisH, faEllipsisV, faEnvelope, + faEquals, faEye, faExclamationTriangle, faExpand, @@ -223,6 +224,7 @@ export const FontAwesomePlugin: Plugin = { addIcon(faEllipsisH); addIcon(faEllipsisV); addIcon(faEnvelope); + addIcon(faEquals); addIcon(faEye); addIcon(faExclamationTriangle); addIcon(faExpand); diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 1580a3bff4..f3bf9f656a 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -57,6 +57,10 @@ const SettingsExternalSecrets = async () => await import('./views/SettingsExtern const WorkerView = async () => await import('./views/WorkerView.vue'); const WorkflowHistory = async () => await import('@/views/WorkflowHistory.vue'); const WorkflowOnboardingView = async () => await import('@/views/WorkflowOnboardingView.vue'); +const TestDefinitionListView = async () => + await import('./views/TestDefinition/TestDefinitionListView.vue'); +const TestDefinitionEditView = async () => + await import('./views/TestDefinition/TestDefinitionEditView.vue'); function getTemplatesRedirect(defaultRedirect: VIEWS[keyof VIEWS]): { name: string } | false { const settingsStore = useSettingsStore(); @@ -249,6 +253,55 @@ export const routes: RouteRecordRaw[] = [ }, ], }, + { + path: '/workflow/:name/evaluation', + name: VIEWS.TEST_DEFINITION, + meta: { + keepWorkflowAlive: true, + middleware: ['authenticated'], + }, + children: [ + { + path: '', + name: VIEWS.TEST_DEFINITION, + components: { + default: TestDefinitionListView, + header: MainHeader, + sidebar: MainSidebar, + }, + meta: { + keepWorkflowAlive: true, + middleware: ['authenticated'], + }, + }, + { + path: 'new', + name: VIEWS.NEW_TEST_DEFINITION, + components: { + default: TestDefinitionEditView, + header: MainHeader, + sidebar: MainSidebar, + }, + meta: { + keepWorkflowAlive: true, + middleware: ['authenticated'], + }, + }, + { + path: ':testId', + name: VIEWS.TEST_DEFINITION_EDIT, + components: { + default: TestDefinitionEditView, + header: MainHeader, + sidebar: MainSidebar, + }, + meta: { + keepWorkflowAlive: true, + middleware: ['authenticated'], + }, + }, + ], + }, { path: '/workflow/:workflowId/history/:versionId?', name: VIEWS.WORKFLOW_HISTORY, diff --git a/packages/editor-ui/src/stores/executions.store.ts b/packages/editor-ui/src/stores/executions.store.ts index b74a63325d..54ee47bacc 100644 --- a/packages/editor-ui/src/stores/executions.store.ts +++ b/packages/editor-ui/src/stores/executions.store.ts @@ -15,10 +15,12 @@ import { useRootStore } from '@/stores/root.store'; import { makeRestApiRequest, unflattenExecutionData } from '@/utils/apiUtils'; import { executionFilterToQueryFilter, getDefaultExecutionFilters } from '@/utils/executionUtils'; import { useProjectsStore } from '@/stores/projects.store'; +import { useSettingsStore } from '@/stores/settings.store'; export const useExecutionsStore = defineStore('executions', () => { const rootStore = useRootStore(); const projectsStore = useProjectsStore(); + const settingsStore = useSettingsStore(); const loading = ref(false); const itemsPerPage = ref(10); @@ -67,12 +69,29 @@ export const useExecutionsStore = defineStore('executions', () => { ); const currentExecutionsById = ref>({}); + const startedAtSortFn = (a: ExecutionSummary, b: ExecutionSummary) => + new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(); + + /** + * Prioritize `running` over `new` executions, then sort by start timestamp. + */ + const statusThenStartedAtSortFn = (a: ExecutionSummary, b: ExecutionSummary) => { + if (a.status && b.status) { + const statusPriority: { [key: string]: number } = { running: 1, new: 2 }; + const statusComparison = statusPriority[a.status] - statusPriority[b.status]; + + if (statusComparison !== 0) return statusComparison; + } + + return startedAtSortFn(a, b); + }; + + const sortFn = settingsStore.isConcurrencyEnabled ? statusThenStartedAtSortFn : startedAtSortFn; + const currentExecutions = computed(() => { const data = Object.values(currentExecutionsById.value); - data.sort((a, b) => { - return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(); - }); + data.sort(sortFn); return data; }); diff --git a/packages/editor-ui/src/stores/settings.store.ts b/packages/editor-ui/src/stores/settings.store.ts index 7d881b74d4..822c05f4dc 100644 --- a/packages/editor-ui/src/stores/settings.store.ts +++ b/packages/editor-ui/src/stores/settings.store.ts @@ -70,6 +70,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { const concurrency = computed(() => settings.value.concurrency); + const isConcurrencyEnabled = computed(() => concurrency.value !== -1); + const isPublicApiEnabled = computed(() => api.value.enabled); const isSwaggerUIEnabled = computed(() => api.value.swaggerUi.enabled); @@ -384,6 +386,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { security, nodeJsVersion, concurrency, + isConcurrencyEnabled, isPublicApiEnabled, isSwaggerUIEnabled, isPreviewMode, diff --git a/packages/editor-ui/src/stores/testDefinition.store.ee.test.ts b/packages/editor-ui/src/stores/testDefinition.store.ee.test.ts new file mode 100644 index 0000000000..702701a495 --- /dev/null +++ b/packages/editor-ui/src/stores/testDefinition.store.ee.test.ts @@ -0,0 +1,264 @@ +import { createPinia, setActivePinia } from 'pinia'; +import { useTestDefinitionStore } from '@/stores/testDefinition.store.ee'; // Adjust the import path as necessary +import { useRootStore } from '@/stores/root.store'; +import { usePostHog } from '@/stores/posthog.store'; +import type { TestDefinitionRecord } from '@/api/testDefinition.ee'; + +const { createTestDefinition, deleteTestDefinition, getTestDefinitions, updateTestDefinition } = + vi.hoisted(() => ({ + getTestDefinitions: vi.fn(), + createTestDefinition: vi.fn(), + updateTestDefinition: vi.fn(), + deleteTestDefinition: vi.fn(), + })); + +vi.mock('@/api/testDefinition.ee', () => ({ + createTestDefinition, + deleteTestDefinition, + getTestDefinitions, + updateTestDefinition, +})); + +vi.mock('@/stores/root.store', () => ({ + useRootStore: vi.fn(() => ({ + restApiContext: { instanceId: 'test-instance-id' }, + })), +})); + +const TEST_DEF_A: TestDefinitionRecord = { + id: '1', + name: 'Test Definition A', + workflowId: '123', + description: 'Description A', +}; +const TEST_DEF_B: TestDefinitionRecord = { + id: '2', + name: 'Test Definition B', + workflowId: '123', + description: 'Description B', +}; +const TEST_DEF_NEW: TestDefinitionRecord = { + id: '3', + name: 'New Test Definition', + workflowId: '123', + description: 'New Description', +}; + +describe('testDefinition.store.ee', () => { + let store: ReturnType; + let rootStoreMock: ReturnType; + let posthogStoreMock: ReturnType; + + beforeEach(() => { + vi.restoreAllMocks(); + setActivePinia(createPinia()); + store = useTestDefinitionStore(); + rootStoreMock = useRootStore(); + posthogStoreMock = usePostHog(); + + getTestDefinitions.mockResolvedValue({ + count: 2, + testDefinitions: [TEST_DEF_A, TEST_DEF_B], + }); + + createTestDefinition.mockResolvedValue(TEST_DEF_NEW); + + deleteTestDefinition.mockResolvedValue({ success: true }); + }); + + test('Initialization', () => { + expect(store.testDefinitionsById).toEqual({}); + expect(store.isLoading).toBe(false); + expect(store.hasTestDefinitions).toBe(false); + }); + + test('Fetching Test Definitions', async () => { + expect(store.isLoading).toBe(false); + + const result = await store.fetchAll(); + + expect(getTestDefinitions).toHaveBeenCalledWith(rootStoreMock.restApiContext); + expect(store.testDefinitionsById).toEqual({ + '1': TEST_DEF_A, + '2': TEST_DEF_B, + }); + expect(store.isLoading).toBe(false); + expect(result).toEqual({ + count: 2, + testDefinitions: [TEST_DEF_A, TEST_DEF_B], + }); + }); + + test('Fetching Test Definitions with force flag', async () => { + expect(store.isLoading).toBe(false); + + const result = await store.fetchAll({ force: true }); + + expect(getTestDefinitions).toHaveBeenCalledWith(rootStoreMock.restApiContext); + expect(store.testDefinitionsById).toEqual({ + '1': TEST_DEF_A, + '2': TEST_DEF_B, + }); + expect(store.isLoading).toBe(false); + expect(result).toEqual({ + count: 2, + testDefinitions: [TEST_DEF_A, TEST_DEF_B], + }); + }); + + test('Fetching Test Definitions when already fetched', async () => { + store.fetchedAll = true; + + const result = await store.fetchAll(); + + expect(getTestDefinitions).not.toHaveBeenCalled(); + expect(store.testDefinitionsById).toEqual({}); + expect(result).toEqual({ + count: 0, + testDefinitions: [], + }); + }); + + test('Upserting Test Definitions - New Definition', () => { + const newDefinition = TEST_DEF_NEW; + + store.upsertTestDefinitions([newDefinition]); + + expect(store.testDefinitionsById).toEqual({ + '3': TEST_DEF_NEW, + }); + }); + + test('Upserting Test Definitions - Existing Definition', () => { + store.testDefinitionsById = { + '1': TEST_DEF_A, + }; + + const updatedDefinition = { + id: '1', + name: 'Updated Test Definition A', + description: 'Updated Description A', + workflowId: '123', + }; + + store.upsertTestDefinitions([updatedDefinition]); + + expect(store.testDefinitionsById).toEqual({ + 1: updatedDefinition, + }); + }); + + test('Deleting Test Definitions', () => { + store.testDefinitionsById = { + '1': TEST_DEF_A, + '2': TEST_DEF_B, + }; + + store.deleteTestDefinition('1'); + + expect(store.testDefinitionsById).toEqual({ + '2': TEST_DEF_B, + }); + }); + + test('Creating a Test Definition', async () => { + const params = { + name: 'New Test Definition', + workflowId: 'test-workflow-id', + evaluationWorkflowId: 'test-evaluation-workflow-id', + description: 'New Description', + }; + + const result = await store.create(params); + + expect(createTestDefinition).toHaveBeenCalledWith(rootStoreMock.restApiContext, params); + expect(store.testDefinitionsById).toEqual({ + '3': TEST_DEF_NEW, + }); + expect(result).toEqual(TEST_DEF_NEW); + }); + + test('Updating a Test Definition', async () => { + store.testDefinitionsById = { + '1': TEST_DEF_A, + '2': TEST_DEF_B, + }; + + const params = { + id: '1', + name: 'Updated Test Definition A', + description: 'Updated Description A', + workflowId: '123', + }; + updateTestDefinition.mockResolvedValue(params); + + const result = await store.update(params); + + expect(updateTestDefinition).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1', { + name: 'Updated Test Definition A', + description: 'Updated Description A', + workflowId: '123', + }); + expect(store.testDefinitionsById).toEqual({ + '1': params, + '2': TEST_DEF_B, + }); + expect(result).toEqual(params); + }); + + test('Deleting a Test Definition by ID', async () => { + store.testDefinitionsById = { + '1': TEST_DEF_A, + }; + + const result = await store.deleteById('1'); + + expect(deleteTestDefinition).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1'); + expect(store.testDefinitionsById).toEqual({}); + expect(result).toBe(true); + }); + + test('Computed Properties - hasTestDefinitions', () => { + store.testDefinitionsById = {}; + + expect(store.hasTestDefinitions).toBe(false); + store.testDefinitionsById = { + '1': TEST_DEF_A, + }; + + expect(store.hasTestDefinitions).toBe(true); + }); + + test('Computed Properties - isFeatureEnabled', () => { + posthogStoreMock.isFeatureEnabled = vi.fn().mockReturnValue(false); + + expect(store.isFeatureEnabled).toBe(false); + posthogStoreMock.isFeatureEnabled = vi.fn().mockReturnValue(true); + + expect(store.isFeatureEnabled).toBe(true); + }); + + test('Error Handling - create', async () => { + createTestDefinition.mockRejectedValue(new Error('Create failed')); + + await expect( + store.create({ name: 'New Test Definition', workflowId: 'test-workflow-id' }), + ).rejects.toThrow('Create failed'); + }); + + test('Error Handling - update', async () => { + updateTestDefinition.mockRejectedValue(new Error('Update failed')); + + await expect(store.update({ id: '1', name: 'Updated Test Definition A' })).rejects.toThrow( + 'Update failed', + ); + }); + + test('Error Handling - deleteById', async () => { + deleteTestDefinition.mockResolvedValue({ success: false }); + + const result = await store.deleteById('1'); + + expect(result).toBe(false); + }); +}); diff --git a/packages/editor-ui/src/stores/testDefinition.store.ee.ts b/packages/editor-ui/src/stores/testDefinition.store.ee.ts new file mode 100644 index 0000000000..6a9e3fe363 --- /dev/null +++ b/packages/editor-ui/src/stores/testDefinition.store.ee.ts @@ -0,0 +1,171 @@ +import { defineStore } from 'pinia'; +import { computed, ref } from 'vue'; +import { useRootStore } from './root.store'; +import * as testDefinitionsApi from '@/api/testDefinition.ee'; +import type { TestDefinitionRecord } from '@/api/testDefinition.ee'; +import { usePostHog } from './posthog.store'; +import { STORES, WORKFLOW_EVALUATION_EXPERIMENT } from '@/constants'; + +export const useTestDefinitionStore = defineStore( + STORES.TEST_DEFINITION, + () => { + // State + const testDefinitionsById = ref>({}); + const loading = ref(false); + const fetchedAll = ref(false); + + // Store instances + const posthogStore = usePostHog(); + const rootStore = useRootStore(); + + // Computed + const allTestDefinitions = computed(() => { + return Object.values(testDefinitionsById.value).sort((a, b) => + (a.name ?? '').localeCompare(b.name ?? ''), + ); + }); + + // Enable with `window.featureFlags.override('025_workflow_evaluation', true)` + const isFeatureEnabled = computed(() => + posthogStore.isFeatureEnabled(WORKFLOW_EVALUATION_EXPERIMENT), + ); + + const isLoading = computed(() => loading.value); + + const hasTestDefinitions = computed(() => Object.keys(testDefinitionsById.value).length > 0); + + // Methods + const setAllTestDefinitions = (definitions: TestDefinitionRecord[]) => { + testDefinitionsById.value = definitions.reduce( + (acc: Record, def: TestDefinitionRecord) => { + acc[def.id] = def; + return acc; + }, + {}, + ); + }; + + /** + * Upserts test definitions in the store. + * @param toUpsertDefinitions - An array of test definitions to upsert. + */ + const upsertTestDefinitions = (toUpsertDefinitions: TestDefinitionRecord[]) => { + toUpsertDefinitions.forEach((toUpsertDef) => { + const defId = toUpsertDef.id; + if (!defId) throw Error('ID is required for upserting'); + const currentDef = testDefinitionsById.value[defId]; + testDefinitionsById.value = { + ...testDefinitionsById.value, + [defId]: { + ...currentDef, + ...toUpsertDef, + }, + }; + }); + }; + + const deleteTestDefinition = (id: string) => { + const { [id]: deleted, ...rest } = testDefinitionsById.value; + testDefinitionsById.value = rest; + }; + + /** + * Fetches all test definitions from the API. + * @param {boolean} force - If true, fetches the definitions from the API even if they were already fetched before. + */ + const fetchAll = async (params?: { force?: boolean }) => { + const { force = false } = params ?? {}; + if (!force && fetchedAll.value) { + const testDefinitions = Object.values(testDefinitionsById.value); + return { + count: testDefinitions.length, + testDefinitions, + }; + } + + loading.value = true; + try { + const retrievedDefinitions = await testDefinitionsApi.getTestDefinitions( + rootStore.restApiContext, + ); + + setAllTestDefinitions(retrievedDefinitions.testDefinitions); + fetchedAll.value = true; + return retrievedDefinitions; + } finally { + loading.value = false; + } + }; + + /** + * Creates a new test definition using the provided parameters. + * + * @param {Object} params - An object containing the necessary parameters to create a test definition. + * @param {string} params.name - The name of the new test definition. + * @param {string} params.workflowId - The ID of the workflow associated with the test definition. + * @returns {Promise} A promise that resolves to the newly created test definition. + * @throws {Error} Throws an error if there is a problem creating the test definition. + */ + const create = async (params: { + name: string; + workflowId: string; + }) => { + const createdDefinition = await testDefinitionsApi.createTestDefinition( + rootStore.restApiContext, + params, + ); + upsertTestDefinitions([createdDefinition]); + return createdDefinition; + }; + + const update = async (params: Partial) => { + if (!params.id) throw new Error('ID is required to update a test definition'); + + const { id, ...updateParams } = params; + const updatedDefinition = await testDefinitionsApi.updateTestDefinition( + rootStore.restApiContext, + id, + updateParams, + ); + upsertTestDefinitions([updatedDefinition]); + return updatedDefinition; + }; + + /** + * Deletes a test definition by its ID. + * + * @param {number} id - The ID of the test definition to delete. + * @returns {Promise} A promise that resolves to true if the test definition was successfully deleted, false otherwise. + */ + const deleteById = async (id: string) => { + const result = await testDefinitionsApi.deleteTestDefinition(rootStore.restApiContext, id); + + if (result.success) { + deleteTestDefinition(id); + } + + return result.success; + }; + + return { + // State + fetchedAll, + testDefinitionsById, + + // Computed + allTestDefinitions, + isLoading, + hasTestDefinitions, + isFeatureEnabled, + + // Methods + fetchAll, + create, + update, + deleteById, + upsertTestDefinitions, + deleteTestDefinition, + }; + }, + {}, +); diff --git a/packages/editor-ui/src/stores/workflows.store.test.ts b/packages/editor-ui/src/stores/workflows.store.test.ts index 1ed19aae24..f6d829c71c 100644 --- a/packages/editor-ui/src/stores/workflows.store.test.ts +++ b/packages/editor-ui/src/stores/workflows.store.test.ts @@ -618,6 +618,51 @@ describe('useWorkflowsStore', () => { }); }); }); + + describe('setNodeValue()', () => { + it('should update a node', () => { + const nodeName = 'Edit Fields'; + workflowsStore.addNode({ + parameters: {}, + id: '554c7ff4-7ee2-407c-8931-e34234c5056a', + name: nodeName, + type: 'n8n-nodes-base.set', + position: [680, 180], + typeVersion: 3.4, + }); + + expect(workflowsStore.nodeMetadata[nodeName].parametersLastUpdatedAt).toBe(undefined); + + workflowsStore.setNodeValue({ name: 'Edit Fields', key: 'executeOnce', value: true }); + + expect(workflowsStore.workflow.nodes[0].executeOnce).toBe(true); + expect(workflowsStore.nodeMetadata[nodeName].parametersLastUpdatedAt).toEqual( + expect.any(Number), + ); + }); + }); + + describe('setNodePositionById()', () => { + it('should NOT update parametersLastUpdatedAt', () => { + const nodeName = 'Edit Fields'; + const nodeId = '554c7ff4-7ee2-407c-8931-e34234c5056a'; + workflowsStore.addNode({ + parameters: {}, + id: nodeId, + name: nodeName, + type: 'n8n-nodes-base.set', + position: [680, 180], + typeVersion: 3.4, + }); + + expect(workflowsStore.nodeMetadata[nodeName].parametersLastUpdatedAt).toBe(undefined); + + workflowsStore.setNodePositionById(nodeId, [0, 0]); + + expect(workflowsStore.workflow.nodes[0].position).toStrictEqual([0, 0]); + expect(workflowsStore.nodeMetadata[nodeName].parametersLastUpdatedAt).toBe(undefined); + }); + }); }); function getMockEditFieldsNode() { diff --git a/packages/editor-ui/src/stores/workflows.store.ts b/packages/editor-ui/src/stores/workflows.store.ts index f49beaed84..0eb235dea2 100644 --- a/packages/editor-ui/src/stores/workflows.store.ts +++ b/packages/editor-ui/src/stores/workflows.store.ts @@ -84,7 +84,7 @@ import { TelemetryHelpers } from 'n8n-workflow'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { useRouter } from 'vue-router'; import { useSettingsStore } from './settings.store'; -import { openPopUpWindow } from '@/utils/executionUtils'; +import { closeFormPopupWindow, openFormPopupWindow } from '@/utils/executionUtils'; import { useNodeHelpers } from '@/composables/useNodeHelpers'; const defaults: Omit & { settings: NonNullable } = { @@ -143,7 +143,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { const chatMessages = ref([]); const isChatPanelOpen = ref(false); const isLogsPanelOpen = ref(false); - const formPopupWindow = ref(null); const workflowName = computed(() => workflow.value.name); @@ -1201,6 +1200,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { updateNodeAtIndex(nodeIndex, { [updateInformation.key]: updateInformation.value, }); + + if (updateInformation.key !== 'position') { + nodeMetadata.value[workflow.value.nodes[nodeIndex].name].parametersLastUpdatedAt = Date.now(); + } } function setNodeParameters(updateInformation: IUpdateInformation, append?: boolean): void { @@ -1319,12 +1322,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { (node.type === WAIT_NODE_TYPE && node.parameters.resume === 'form') ) { const testUrl = getFormResumeUrl(node, executionId); - if (!formPopupWindow.value || formPopupWindow.value.closed) { - formPopupWindow.value = openPopUpWindow(testUrl); - } else { - formPopupWindow.value.location = testUrl; - formPopupWindow.value.focus(); - } + openFormPopupWindow(testUrl); } } else { if (tasksData.length && tasksData[tasksData.length - 1].executionStatus === 'waiting') { @@ -1577,8 +1575,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { uiStore.removeActiveAction('workflowRunning'); workflowHelpers.setDocumentTitle(workflowName.value, 'IDLE'); - formPopupWindow.value?.close(); - formPopupWindow.value = null; + closeFormPopupWindow(); const runData = workflowExecutionData.value?.data?.resultData.runData ?? {}; for (const nodeName in runData) { diff --git a/packages/editor-ui/src/types/canvas.ts b/packages/editor-ui/src/types/canvas.ts index 96d24ea453..ad8d51ba8a 100644 --- a/packages/editor-ui/src/types/canvas.ts +++ b/packages/editor-ui/src/types/canvas.ts @@ -5,7 +5,14 @@ import type { IConnection, NodeConnectionType, } from 'n8n-workflow'; -import type { DefaultEdge, Node, NodeProps, Position, OnConnectStartParams } from '@vue-flow/core'; +import type { + DefaultEdge, + Node, + NodeProps, + Position, + OnConnectStartParams, + ViewportTransform, +} from '@vue-flow/core'; import type { IExecutionResponse, INodeUi } from '@/Interface'; import type { ComputedRef, Ref } from 'vue'; import type { PartialBy } from '@/utils/typeHelpers'; @@ -59,6 +66,7 @@ export type CanvasNodeDefaultRender = { outputs: { labelSize: CanvasNodeDefaultRenderLabelSize; }; + tooltip?: string; }>; }; @@ -135,8 +143,10 @@ export type CanvasConnectionCreateData = { }; export interface CanvasInjectionData { + initialized: Ref; isExecuting: Ref; connectingHandle: Ref; + viewport: Ref; } export type CanvasNodeEventBusEvents = { diff --git a/packages/editor-ui/src/utils/executionUtils.test.ts b/packages/editor-ui/src/utils/executionUtils.test.ts index abcab9509f..a4f87cf3f1 100644 --- a/packages/editor-ui/src/utils/executionUtils.test.ts +++ b/packages/editor-ui/src/utils/executionUtils.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { displayForm, - openPopUpWindow, + openFormPopupWindow, executionFilterToQueryFilter, waitingNodeTooltip, } from './executionUtils'; @@ -15,7 +15,7 @@ vi.mock('./executionUtils', async () => { const actual = await vi.importActual('./executionUtils'); return { ...actual, - openPopUpWindow: vi.fn(), + openFormPopupWindow: vi.fn(), }; }); @@ -86,7 +86,7 @@ describe('displayForm', () => { getTestUrl: getTestUrlMock, }); - expect(openPopUpWindow).not.toHaveBeenCalled(); + expect(openFormPopupWindow).not.toHaveBeenCalled(); }); it('should skip nodes if destinationNode does not match and node is not a directParentNode', () => { @@ -119,7 +119,7 @@ describe('displayForm', () => { getTestUrl: getTestUrlMock, }); - expect(openPopUpWindow).not.toHaveBeenCalled(); + expect(openFormPopupWindow).not.toHaveBeenCalled(); }); it('should not open pop-up if source is "RunData.ManualChatMessage"', () => { @@ -146,7 +146,7 @@ describe('displayForm', () => { getTestUrl: getTestUrlMock, }); - expect(openPopUpWindow).not.toHaveBeenCalled(); + expect(openFormPopupWindow).not.toHaveBeenCalled(); }); describe('executionFilterToQueryFilter()', () => { diff --git a/packages/editor-ui/src/utils/executionUtils.ts b/packages/editor-ui/src/utils/executionUtils.ts index 014f3492b6..d8db7b8f8e 100644 --- a/packages/editor-ui/src/utils/executionUtils.ts +++ b/packages/editor-ui/src/utils/executionUtils.ts @@ -82,25 +82,28 @@ export const executionFilterToQueryFilter = ( return queryFilter; }; -export const openPopUpWindow = ( - url: string, - options?: { width?: number; height?: number; alwaysInNewTab?: boolean }, -) => { - const windowWidth = window.innerWidth; - const smallScreen = windowWidth <= 800; - if (options?.alwaysInNewTab || smallScreen) { - return window.open(url, '_blank'); - } else { - const height = options?.width || 700; - const width = options?.height || window.innerHeight - 50; +let formPopupWindow: Window | null = null; + +export const openFormPopupWindow = (url: string) => { + if (!formPopupWindow || formPopupWindow.closed) { + const height = 700; + const width = window.innerHeight - 50; const left = (window.innerWidth - height) / 2; const top = 50; const features = `width=${height},height=${width},left=${left},top=${top},resizable=yes,scrollbars=yes`; const windowName = `form-waiting-since-${Date.now()}`; - return window.open(url, windowName, features); + formPopupWindow = window.open(url, windowName, features); + } else { + formPopupWindow.location = url; + formPopupWindow.focus(); } }; +export const closeFormPopupWindow = () => { + formPopupWindow?.close(); + formPopupWindow = null; +}; + export function displayForm({ nodes, runData, @@ -131,7 +134,7 @@ export function displayForm({ if (node.name === destinationNode || !node.disabled) { let testUrl = ''; if (node.type === FORM_TRIGGER_NODE_TYPE) testUrl = getTestUrl(node); - if (testUrl && source !== 'RunData.ManualChatMessage') openPopUpWindow(testUrl); + if (testUrl && source !== 'RunData.ManualChatMessage') openFormPopupWindow(testUrl); } } } diff --git a/packages/editor-ui/src/utils/expressions.ts b/packages/editor-ui/src/utils/expressions.ts index d641a27b53..d572006ad5 100644 --- a/packages/editor-ui/src/utils/expressions.ts +++ b/packages/editor-ui/src/utils/expressions.ts @@ -78,7 +78,7 @@ export const getResolvableState = (error: unknown, ignoreError = false): Resolva return 'invalid'; }; -export const getExpressionErrorMessage = (error: Error): string => { +export const getExpressionErrorMessage = (error: Error, nodeHasRunData = false): string => { if (isNoExecDataExpressionError(error) || isPairedItemIntermediateNodesError(error)) { return i18n.baseText('expressionModalInput.noExecutionData'); } @@ -109,19 +109,24 @@ export const getExpressionErrorMessage = (error: Error): string => { } if (isAnyPairedItemError(error)) { - return i18n.baseText('expressionModalInput.pairedItemError'); + return nodeHasRunData + ? i18n.baseText('expressionModalInput.pairedItemError') + : i18n.baseText('expressionModalInput.pairedItemError.noRunData'); } return error.message; }; -export const stringifyExpressionResult = (result: Result): string => { +export const stringifyExpressionResult = ( + result: Result, + nodeHasRunData = false, +): string => { if (!result.ok) { if (getResolvableState(result.error) !== 'invalid') { return ''; } - return `[${i18n.baseText('parameterInput.error')}: ${getExpressionErrorMessage(result.error)}]`; + return `[${i18n.baseText('parameterInput.error')}: ${getExpressionErrorMessage(result.error, nodeHasRunData)}]`; } if (result.result === null) { diff --git a/packages/editor-ui/src/utils/injectStrict.test.ts b/packages/editor-ui/src/utils/injectStrict.test.ts new file mode 100644 index 0000000000..d8051d7a96 --- /dev/null +++ b/packages/editor-ui/src/utils/injectStrict.test.ts @@ -0,0 +1,35 @@ +import { injectStrict } from '@/utils/injectStrict'; +import type { InjectionKey } from 'vue'; +import { inject } from 'vue'; + +vi.mock('vue', async () => { + const original = await vi.importActual('vue'); + return { + ...original, + inject: vi.fn(), + }; +}); + +describe('injectStrict', () => { + it('should return the injected value when it exists', () => { + const key = Symbol('testKey') as InjectionKey; + const value = 'testValue'; + vi.mocked(inject).mockReturnValueOnce(value); + const result = injectStrict(key); + expect(result).toBe(value); + }); + + it('should return the fallback value when the injected value does not exist', () => { + const key = Symbol('testKey') as InjectionKey; + const fallback = 'fallbackValue'; + vi.mocked(inject).mockReturnValueOnce(fallback); + const result = injectStrict(key, fallback); + expect(result).toBe(fallback); + }); + + it('should throw an error when the injected value does not exist and no fallback is provided', () => { + const key = Symbol('testKey') as InjectionKey; + vi.mocked(inject).mockReturnValueOnce(undefined); + expect(() => injectStrict(key)).toThrowError(`Could not resolve ${key.description}`); + }); +}); diff --git a/packages/editor-ui/src/utils/injectStrict.ts b/packages/editor-ui/src/utils/injectStrict.ts new file mode 100644 index 0000000000..d232927840 --- /dev/null +++ b/packages/editor-ui/src/utils/injectStrict.ts @@ -0,0 +1,10 @@ +import type { InjectionKey } from 'vue'; +import { inject } from 'vue'; + +export function injectStrict(key: InjectionKey, fallback?: T) { + const resolved = inject(key, fallback); + if (!resolved) { + throw new Error(`Could not resolve ${key.description}`); + } + return resolved; +} diff --git a/packages/editor-ui/src/views/NodeViewSwitcher.vue b/packages/editor-ui/src/views/NodeViewSwitcher.vue index d40f92e626..d68d29b0b6 100644 --- a/packages/editor-ui/src/views/NodeViewSwitcher.vue +++ b/packages/editor-ui/src/views/NodeViewSwitcher.vue @@ -1,5 +1,5 @@ + + + + diff --git a/packages/editor-ui/src/views/TestDefinition/TestDefinitionListView.vue b/packages/editor-ui/src/views/TestDefinition/TestDefinitionListView.vue new file mode 100644 index 0000000000..33a966b4b7 --- /dev/null +++ b/packages/editor-ui/src/views/TestDefinition/TestDefinitionListView.vue @@ -0,0 +1,153 @@ + + + + diff --git a/packages/editor-ui/src/views/TestDefinition/tests/TestDefinitionEditView.test.ts b/packages/editor-ui/src/views/TestDefinition/tests/TestDefinitionEditView.test.ts new file mode 100644 index 0000000000..9aeb70679b --- /dev/null +++ b/packages/editor-ui/src/views/TestDefinition/tests/TestDefinitionEditView.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; +import { createTestingPinia } from '@pinia/testing'; +import { createComponentRenderer } from '@/__tests__/render'; +import TestDefinitionEditView from '@/views/TestDefinition/TestDefinitionEditView.vue'; +import { useRoute, useRouter } from 'vue-router'; +import { useToast } from '@/composables/useToast'; +import { useTestDefinitionForm } from '@/components/TestDefinition/composables/useTestDefinitionForm'; +import { useAnnotationTagsStore } from '@/stores/tags.store'; +import { ref, nextTick } from 'vue'; + +vi.mock('vue-router'); +vi.mock('@/composables/useToast'); +vi.mock('@/components/TestDefinition/composables/useTestDefinitionForm'); +vi.mock('@/stores/tags.store'); +vi.mock('@/stores/projects.store'); + +describe('TestDefinitionEditView', () => { + const renderComponent = createComponentRenderer(TestDefinitionEditView); + + beforeEach(() => { + setActivePinia(createPinia()); + + vi.mocked(useRoute).mockReturnValue({ + params: {}, + path: '/test-path', + name: 'test-route', + } as ReturnType); + + vi.mocked(useRouter).mockReturnValue({ + push: vi.fn(), + resolve: vi.fn().mockReturnValue({ href: '/test-href' }), + } as unknown as ReturnType); + + vi.mocked(useToast).mockReturnValue({ + showMessage: vi.fn(), + showError: vi.fn(), + } as unknown as ReturnType); + vi.mocked(useTestDefinitionForm).mockReturnValue({ + state: ref({ + name: { value: '', isEditing: false, tempValue: '' }, + description: '', + tags: { appliedTagIds: [], isEditing: false }, + evaluationWorkflow: { id: '1', name: 'Test Workflow' }, + metrics: [], + }), + fieldsIssues: ref([]), + isSaving: ref(false), + loadTestData: vi.fn(), + saveTest: vi.fn(), + startEditing: vi.fn(), + saveChanges: vi.fn(), + cancelEditing: vi.fn(), + handleKeydown: vi.fn(), + } as unknown as ReturnType); + vi.mocked(useAnnotationTagsStore).mockReturnValue({ + isLoading: ref(false), + allTags: ref([]), + tagsById: ref({}), + fetchAll: vi.fn(), + } as unknown as ReturnType); + + vi.mock('@/stores/projects.store', () => ({ + useProjectsStore: vi.fn().mockReturnValue({ + isTeamProjectFeatureEnabled: false, + currentProject: null, + currentProjectId: null, + }), + })); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should load test data when testId is provided', async () => { + vi.mocked(useRoute).mockReturnValue({ + params: { testId: '1' }, + path: '/test-path', + name: 'test-route', + } as unknown as ReturnType); + const loadTestDataMock = vi.fn(); + vi.mocked(useTestDefinitionForm).mockReturnValue({ + ...vi.mocked(useTestDefinitionForm)(), + loadTestData: loadTestDataMock, + } as unknown as ReturnType); + + renderComponent({ + pinia: createTestingPinia(), + }); + + await nextTick(); + expect(loadTestDataMock).toHaveBeenCalledWith('1'); + }); + + it('should not load test data when testId is not provided', async () => { + const loadTestDataMock = vi.fn(); + vi.mocked(useTestDefinitionForm).mockReturnValue({ + ...vi.mocked(useTestDefinitionForm)(), + loadTestData: loadTestDataMock, + } as unknown as ReturnType); + + renderComponent({ + pinia: createTestingPinia(), + }); + + await nextTick(); + expect(loadTestDataMock).not.toHaveBeenCalled(); + }); + + it('should save test and show success message on successful save', async () => { + const saveTestMock = vi.fn().mockResolvedValue({}); + const routerPushMock = vi.fn(); + const routerResolveMock = vi.fn().mockReturnValue({ href: '/test-href' }); + vi.mocked(useTestDefinitionForm).mockReturnValue({ + ...vi.mocked(useTestDefinitionForm)(), + createTest: saveTestMock, + } as unknown as ReturnType); + + vi.mocked(useRouter).mockReturnValue({ + push: routerPushMock, + resolve: routerResolveMock, + } as unknown as ReturnType); + + const { getByTestId } = renderComponent({ + pinia: createTestingPinia(), + }); + await nextTick(); + const saveButton = getByTestId('run-test-button'); + saveButton.click(); + await nextTick(); + + expect(saveTestMock).toHaveBeenCalled(); + }); + + it('should show error message on failed save', async () => { + const saveTestMock = vi.fn().mockRejectedValue(new Error('Save failed')); + const showErrorMock = vi.fn(); + vi.mocked(useTestDefinitionForm).mockReturnValue({ + ...vi.mocked(useTestDefinitionForm)(), + createTest: saveTestMock, + } as unknown as ReturnType); + vi.mocked(useToast).mockReturnValue({ showError: showErrorMock } as unknown as ReturnType< + typeof useToast + >); + + const { getByTestId } = renderComponent({ + pinia: createTestingPinia(), + }); + await nextTick(); + const saveButton = getByTestId('run-test-button'); + saveButton.click(); + await nextTick(); + expect(saveTestMock).toHaveBeenCalled(); + expect(showErrorMock).toHaveBeenCalled(); + }); + + it('should display "Update Test" button when editing existing test', async () => { + vi.mocked(useRoute).mockReturnValue({ + params: { testId: '1' }, + path: '/test-path', + name: 'test-route', + } as unknown as ReturnType); + const { getByTestId } = renderComponent({ + pinia: createTestingPinia(), + }); + await nextTick(); + const updateButton = getByTestId('run-test-button'); + expect(updateButton.textContent).toContain('Update test'); + }); + + it('should display "Run Test" button when creating new test', async () => { + const { getByTestId } = renderComponent({ + pinia: createTestingPinia(), + }); + await nextTick(); + const saveButton = getByTestId('run-test-button'); + expect(saveButton).toBeTruthy(); + }); + + it('should apply "has-issues" class to inputs with issues', async () => { + vi.mocked(useTestDefinitionForm).mockReturnValue({ + ...vi.mocked(useTestDefinitionForm)(), + fieldsIssues: ref([{ field: 'name' }, { field: 'tags' }]), + } as unknown as ReturnType); + + const { container } = renderComponent({ + pinia: createTestingPinia(), + }); + await nextTick(); + expect(container.querySelector('.has-issues')).toBeTruthy(); + }); + + it('should fetch all tags on mount', async () => { + const fetchAllMock = vi.fn(); + vi.mocked(useAnnotationTagsStore).mockReturnValue({ + ...vi.mocked(useAnnotationTagsStore)(), + fetchAll: fetchAllMock, + } as unknown as ReturnType); + + renderComponent({ + pinia: createTestingPinia(), + }); + + await nextTick(); + expect(fetchAllMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/editor-ui/src/vue-virtual-scroller.d.ts b/packages/editor-ui/src/vue-virtual-scroller.d.ts new file mode 100644 index 0000000000..1e6ef322aa --- /dev/null +++ b/packages/editor-ui/src/vue-virtual-scroller.d.ts @@ -0,0 +1,112 @@ +declare module 'vue-virtual-scroller' { + import { + type ObjectEmitsOptions, + type PublicProps, + type SetupContext, + type SlotsType, + type VNode, + } from 'vue'; + + interface RecycleScrollerProps { + items: readonly T[]; + direction?: 'vertical' | 'horizontal'; + itemSize?: number | null; + gridItems?: number; + itemSecondarySize?: number; + minItemSize?: number; + sizeField?: string; + typeField?: string; + keyField?: keyof T; + pageMode?: boolean; + prerender?: number; + buffer?: number; + emitUpdate?: boolean; + updateInterval?: number; + listClass?: string; + itemClass?: string; + listTag?: string; + itemTag?: string; + } + + interface DynamicScrollerProps extends RecycleScrollerProps { + minItemSize: number; + } + + interface RecycleScrollerEmitOptions extends ObjectEmitsOptions { + resize: () => void; + visible: () => void; + hidden: () => void; + update: ( + startIndex: number, + endIndex: number, + visibleStartIndex: number, + visibleEndIndex: number, + ) => void; + 'scroll-start': () => void; + 'scroll-end': () => void; + } + + interface RecycleScrollerSlotProps { + item: T; + index: number; + active: boolean; + } + + interface RecycleScrollerSlots { + default(slotProps: RecycleScrollerSlotProps): unknown; + before(): unknown; + empty(): unknown; + after(): unknown; + } + + export interface RecycleScrollerInstance { + getScroll(): { start: number; end: number }; + scrollToItem(index: number): void; + scrollToPosition(position: number): void; + } + + export const RecycleScroller: ( + props: RecycleScrollerProps & PublicProps, + ctx?: SetupContext>>, + expose?: (exposed: RecycleScrollerInstance) => void, + ) => VNode & { + __ctx?: { + props: RecycleScrollerProps & PublicProps; + expose(exposed: RecycleScrollerInstance): void; + slots: RecycleScrollerSlots; + }; + }; + + export const DynamicScroller: ( + props: DynamicScrollerProps & PublicProps, + ctx?: SetupContext>>, + expose?: (exposed: RecycleScrollerInstance) => void, + ) => VNode & { + __ctx?: { + props: DynamicScrollerProps & PublicProps; + expose(exposed: RecycleScrollerInstance): void; + slots: RecycleScrollerSlots; + }; + }; + + interface DynamicScrollerItemProps { + item: T; + active: boolean; + sizeDependencies?: unknown[]; + watchData?: boolean; + tag?: string; + emitResize?: boolean; + onResize?: () => void; + } + + interface DynamicScrollerItemEmitOptions extends ObjectEmitsOptions { + resize: () => void; + } + + export const DynamicScrollerItem: ( + props: DynamicScrollerItemProps & PublicProps, + ctx?: SetupContext, + ) => VNode; + + export function IdState(options?: { idProp?: (value: any) => unknown }): unknown; +} diff --git a/packages/node-dev/package.json b/packages/node-dev/package.json index fabe50dbdd..aa4088cf45 100644 --- a/packages/node-dev/package.json +++ b/packages/node-dev/package.json @@ -1,6 +1,6 @@ { "name": "n8n-node-dev", - "version": "1.69.0", + "version": "1.70.0", "description": "CLI to simplify n8n credentials/node development", "main": "dist/src/index", "types": "dist/src/index.d.ts", diff --git a/packages/nodes-base/credentials/Aws.credentials.ts b/packages/nodes-base/credentials/Aws.credentials.ts index 9170657a4f..bb7b30d4cf 100644 --- a/packages/nodes-base/credentials/Aws.credentials.ts +++ b/packages/nodes-base/credentials/Aws.credentials.ts @@ -330,7 +330,7 @@ export class Aws implements ICredentialType { endpoint.searchParams.set('Version', '2011-06-15'); } } catch (err) { - console.log(err); + console.error(err); } } const parsed = parseAwsUrl(endpoint); @@ -372,7 +372,7 @@ export class Aws implements ICredentialType { customUrl.searchParams.set('Action', 'GetCallerIdentity'); customUrl.searchParams.set('Version', '2011-06-15'); } catch (err) { - console.log(err); + console.error(err); } } endpoint = customUrl; @@ -411,7 +411,7 @@ export class Aws implements ICredentialType { try { sign(signOpts, securityHeaders); } catch (err) { - console.log(err); + console.error(err); } const options: IHttpRequestOptions = { ...requestOptions, diff --git a/packages/nodes-base/nodes/AiTransform/AiTransform.node.ts b/packages/nodes-base/nodes/AiTransform/AiTransform.node.ts index bd3e677a2d..59b4618d88 100644 --- a/packages/nodes-base/nodes/AiTransform/AiTransform.node.ts +++ b/packages/nodes-base/nodes/AiTransform/AiTransform.node.ts @@ -121,7 +121,7 @@ export class AiTransform implements INodeType { sandbox.on( 'output', workflowMode === 'manual' - ? this.sendMessageToUI + ? this.sendMessageToUI.bind(this) : CODE_ENABLE_STDOUT === 'true' ? (...args) => console.log(`[Workflow "${this.getWorkflow().id}"][Node "${node.name}"]`, ...args) diff --git a/packages/nodes-base/nodes/Code/Code.node.ts b/packages/nodes-base/nodes/Code/Code.node.ts index 65028f037e..99604a712a 100644 --- a/packages/nodes-base/nodes/Code/Code.node.ts +++ b/packages/nodes-base/nodes/Code/Code.node.ts @@ -17,7 +17,7 @@ import { JavaScriptSandbox } from './JavaScriptSandbox'; import { JsTaskRunnerSandbox } from './JsTaskRunnerSandbox'; import { PythonSandbox } from './PythonSandbox'; import { getSandboxContext } from './Sandbox'; -import { standardizeOutput } from './utils'; +import { addPostExecutionWarning, standardizeOutput } from './utils'; const { CODE_ENABLE_STDOUT } = process.env; @@ -133,7 +133,7 @@ export class Code implements INodeType { sandbox.on( 'output', workflowMode === 'manual' - ? this.sendMessageToUI + ? this.sendMessageToUI.bind(this) : CODE_ENABLE_STDOUT === 'true' ? (...args) => console.log(`[Workflow "${this.getWorkflow().id}"][Node "${node.name}"]`, ...args) @@ -142,6 +142,8 @@ export class Code implements INodeType { return sandbox; }; + const inputDataItems = this.getInputData(); + // ---------------------------------- // runOnceForAllItems // ---------------------------------- @@ -163,7 +165,7 @@ export class Code implements INodeType { standardizeOutput(item.json); } - return [items]; + return addPostExecutionWarning(items, inputDataItems?.length); } // ---------------------------------- @@ -172,9 +174,7 @@ export class Code implements INodeType { const returnData: INodeExecutionData[] = []; - const items = this.getInputData(); - - for (let index = 0; index < items.length; index++) { + for (let index = 0; index < inputDataItems.length; index++) { const sandbox = getSandbox(index); let result: INodeExecutionData | undefined; try { @@ -201,6 +201,6 @@ export class Code implements INodeType { } } - return [returnData]; + return addPostExecutionWarning(returnData, inputDataItems?.length); } } diff --git a/packages/nodes-base/nodes/Code/Sandbox.ts b/packages/nodes-base/nodes/Code/Sandbox.ts index 917de0ecc1..e11a078f54 100644 --- a/packages/nodes-base/nodes/Code/Sandbox.ts +++ b/packages/nodes-base/nodes/Code/Sandbox.ts @@ -35,8 +35,8 @@ export function getSandboxContext( }; return { // from NodeExecuteFunctions - $getNodeParameter: this.getNodeParameter, - $getWorkflowStaticData: this.getWorkflowStaticData, + $getNodeParameter: this.getNodeParameter.bind(this), + $getWorkflowStaticData: this.getWorkflowStaticData.bind(this), helpers, // to bring in all $-prefixed vars and methods from WorkflowDataProxy diff --git a/packages/nodes-base/nodes/Code/test/Code.node.test.ts b/packages/nodes-base/nodes/Code/test/Code.node.test.ts index 4331b3b7e8..3f1ed0eccf 100644 --- a/packages/nodes-base/nodes/Code/test/Code.node.test.ts +++ b/packages/nodes-base/nodes/Code/test/Code.node.test.ts @@ -57,7 +57,8 @@ describe('Code Node unit test', () => { jest.spyOn(NodeVM.prototype, 'run').mockResolvedValueOnce(input); const output = await node.execute.call(thisArg); - expect(output).toEqual([expected]); + + expect([...output]).toEqual([expected]); }), ); }); @@ -109,7 +110,7 @@ describe('Code Node unit test', () => { jest.spyOn(NodeVM.prototype, 'run').mockResolvedValueOnce(input); const output = await node.execute.call(thisArg); - expect(output).toEqual([[{ json: expected?.json, pairedItem: { item: 0 } }]]); + expect([...output]).toEqual([[{ json: expected?.json, pairedItem: { item: 0 } }]]); }), ); }); diff --git a/packages/nodes-base/nodes/Code/test/utils.test.ts b/packages/nodes-base/nodes/Code/test/utils.test.ts new file mode 100644 index 0000000000..b622c86661 --- /dev/null +++ b/packages/nodes-base/nodes/Code/test/utils.test.ts @@ -0,0 +1,48 @@ +import type { INodeExecutionData } from 'n8n-workflow'; +import { NodeExecutionOutput } from 'n8n-workflow'; +import { addPostExecutionWarning } from '../utils'; + +describe('addPostExecutionWarning', () => { + const inputItemsLength = 2; + + it('should return a NodeExecutionOutput warning when returnData length differs from inputItemsLength', () => { + const returnData: INodeExecutionData[] = [{ json: {}, pairedItem: 0 }]; + + const result = addPostExecutionWarning(returnData, inputItemsLength); + + expect(result).toBeInstanceOf(NodeExecutionOutput); + expect((result as NodeExecutionOutput)?.getHints()).toEqual([ + { + message: + 'To make sure expressions after this node work, return the input items that produced each output item. More info', + location: 'outputPane', + }, + ]); + }); + + it('should return a NodeExecutionOutput warning when any item has undefined pairedItem', () => { + const returnData: INodeExecutionData[] = [{ json: {}, pairedItem: 0 }, { json: {} }]; + + const result = addPostExecutionWarning(returnData, inputItemsLength); + + expect(result).toBeInstanceOf(NodeExecutionOutput); + expect((result as NodeExecutionOutput)?.getHints()).toEqual([ + { + message: + 'To make sure expressions after this node work, return the input items that produced each output item. More info', + location: 'outputPane', + }, + ]); + }); + + it('should return returnData array when all items match inputItemsLength and have defined pairedItem', () => { + const returnData: INodeExecutionData[] = [ + { json: {}, pairedItem: 0 }, + { json: {}, pairedItem: 1 }, + ]; + + const result = addPostExecutionWarning(returnData, inputItemsLength); + + expect(result).toEqual([returnData]); + }); +}); diff --git a/packages/nodes-base/nodes/Code/utils.ts b/packages/nodes-base/nodes/Code/utils.ts index 28fa00f829..410da572b9 100644 --- a/packages/nodes-base/nodes/Code/utils.ts +++ b/packages/nodes-base/nodes/Code/utils.ts @@ -1,4 +1,5 @@ -import type { IDataObject } from 'n8n-workflow'; +import type { INodeExecutionData, IDataObject } from 'n8n-workflow'; +import { NodeExecutionOutput } from 'n8n-workflow'; export function isObject(maybe: unknown): maybe is { [key: string]: unknown } { return ( @@ -36,3 +37,26 @@ export function standardizeOutput(output: IDataObject) { standardizeOutputRecursive(output); return output; } + +export const addPostExecutionWarning = ( + returnData: INodeExecutionData[], + inputItemsLength: number, +) => { + if ( + returnData.length !== inputItemsLength || + returnData.some((item) => item.pairedItem === undefined) + ) { + return new NodeExecutionOutput( + [returnData], + [ + { + message: + 'To make sure expressions after this node work, return the input items that produced each output item. More info', + location: 'outputPane', + }, + ], + ); + } + + return [returnData]; +}; diff --git a/packages/nodes-base/nodes/DebugHelper/functions.ts b/packages/nodes-base/nodes/DebugHelper/functions.ts index a740b577b7..867ef873da 100644 --- a/packages/nodes-base/nodes/DebugHelper/functions.ts +++ b/packages/nodes-base/nodes/DebugHelper/functions.ts @@ -7,7 +7,7 @@ export const runGarbageCollector = () => { const gc = runInNewContext('gc'); // nocommit gc(); } catch (error) { - console.log(error); + console.error(error); } }; @@ -23,8 +23,5 @@ export const generateGarbageMemory = (sizeInMB: number, onHeap = true) => { const array = new Uint8Array(size); array.fill(0); } - // const used = process.memoryUsage().heapUsed / 1024 / 1024; - // const external = process.memoryUsage().external / 1024 / 1024; - // console.log(`heap: ${used} MB / external: ${external} MB`); return { ...process.memoryUsage() }; }; diff --git a/packages/nodes-base/nodes/Function/Function.node.ts b/packages/nodes-base/nodes/Function/Function.node.ts index 6f2955e754..4819e27642 100644 --- a/packages/nodes-base/nodes/Function/Function.node.ts +++ b/packages/nodes-base/nodes/Function/Function.node.ts @@ -92,8 +92,8 @@ return items;`, // Define the global objects for the custom function const sandbox = { - getNodeParameter: this.getNodeParameter, - getWorkflowStaticData: this.getWorkflowStaticData, + getNodeParameter: this.getNodeParameter.bind(this), + getWorkflowStaticData: this.getWorkflowStaticData.bind(this), helpers: this.helpers, items, // To be able to access data of other items @@ -157,7 +157,7 @@ return items;`, const vm = new NodeVM(options); if (mode === 'manual') { - vm.on('console.log', this.sendMessageToUI); + vm.on('console.log', this.sendMessageToUI.bind(this)); } // Get the code to execute diff --git a/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts b/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts index 9bc3be35d1..9f08f05c8e 100644 --- a/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts +++ b/packages/nodes-base/nodes/FunctionItem/FunctionItem.node.ts @@ -113,8 +113,8 @@ return item;`, } item.binary = data; }, - getNodeParameter: this.getNodeParameter, - getWorkflowStaticData: this.getWorkflowStaticData, + getNodeParameter: this.getNodeParameter.bind(this), + getWorkflowStaticData: this.getWorkflowStaticData.bind(this), helpers: this.helpers, item: item.json, getBinaryDataAsync: async (): Promise => { @@ -165,7 +165,7 @@ return item;`, const vm = new NodeVM(options as unknown as NodeVMOptions); if (mode === 'manual') { - vm.on('console.log', this.sendMessageToUI); + vm.on('console.log', this.sendMessageToUI.bind(this)); } // Get the code to execute diff --git a/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/create.operation.ts b/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/create.operation.ts index 3780ea05a5..b5f7980c69 100644 --- a/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/create.operation.ts +++ b/packages/nodes-base/nodes/Google/Drive/v2/actions/drive/create.operation.ts @@ -159,7 +159,7 @@ const properties: INodeProperties[] = [ name: 'canShare', type: 'boolean', default: false, - description: 'Whether the current user can rename this shared drive', + description: 'Whether the current user can share files or folders in this shared drive', }, { displayName: 'Can Trash Children', diff --git a/packages/nodes-base/nodes/HaloPSA/GenericFunctions.ts b/packages/nodes-base/nodes/HaloPSA/GenericFunctions.ts index f6ac15258d..354a5b4f13 100644 --- a/packages/nodes-base/nodes/HaloPSA/GenericFunctions.ts +++ b/packages/nodes-base/nodes/HaloPSA/GenericFunctions.ts @@ -99,7 +99,7 @@ export async function haloPSAApiRequest( if (method === 'DELETE' || method === 'GET' || (method === 'POST' && message)) { let newErrorMessage; if (message.includes('400')) { - console.log(message); + this.logger.debug(message); newErrorMessage = JSON.parse(message.split(' - ')[1]); (error as JsonObject).message = `For field ID, ${ newErrorMessage.id || newErrorMessage['[0].id'] @@ -136,14 +136,14 @@ export async function haloPSAApiRequest( // )) as IDataObject; // const { tickets } = response; -// console.log((tickets as IDataObject[]).map(t => t.id)); +// this.logger.debug((tickets as IDataObject[]).map(t => t.id)); // const body: IDataObject = { // id: clientId, // client_id: reasigmentCliendId, // }; // for (const ticket of (tickets as IDataObject[])) { -// console.log(ticket.id); +// this.logger.debug(ticket.id); // await haloPSAApiRequest.call(this, 'DELETE', `/tickets/${ticket.id}`, accessToken); // } // } diff --git a/packages/nodes-base/nodes/Ldap/Ldap.node.ts b/packages/nodes-base/nodes/Ldap/Ldap.node.ts index 52e9cd9d15..aa8f8413fc 100644 --- a/packages/nodes-base/nodes/Ldap/Ldap.node.ts +++ b/packages/nodes-base/nodes/Ldap/Ldap.node.ts @@ -129,7 +129,7 @@ export class Ldap implements INodeType { await client.bind(credentials.bindDN as string, credentials.bindPassword as string); } catch (error) { await client.unbind(); - console.log(error); + this.logger.error(error); return []; } @@ -138,7 +138,7 @@ export class Ldap implements INodeType { try { results = await client.search(baseDN, { sizeLimit: 200, paged: false }); // should this size limit be set in credentials? } catch (error) { - console.log(error); + this.logger.error(error); return []; } finally { await client.unbind(); @@ -158,7 +158,7 @@ export class Ldap implements INodeType { await client.bind(credentials.bindDN as string, credentials.bindPassword as string); } catch (error) { await client.unbind(); - console.log(error); + this.logger.error(error); return []; } @@ -168,7 +168,7 @@ export class Ldap implements INodeType { try { results = await client.search(baseDN, { sizeLimit: 10, paged: false }); // should this size limit be set in credentials? } catch (error) { - console.log(error); + this.logger.error(error); return []; } finally { await client.unbind(); @@ -202,7 +202,7 @@ export class Ldap implements INodeType { await client.bind(credentials.bindDN as string, credentials.bindPassword as string); } catch (error) { await client.unbind(); - console.log(error); + this.logger.error(error); return []; } @@ -211,7 +211,7 @@ export class Ldap implements INodeType { try { results = await client.search(baseDN, { sizeLimit: 1, paged: false }); } catch (error) { - console.log(error); + this.logger.error(error); return []; } finally { await client.unbind(); diff --git a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/versionDescription.ts b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/versionDescription.ts index 88d111731c..24fccf49a4 100644 --- a/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/versionDescription.ts +++ b/packages/nodes-base/nodes/Microsoft/Excel/v2/actions/versionDescription.ts @@ -27,7 +27,7 @@ export const versionDescription: INodeTypeDescription = { properties: [ { displayName: - 'This node connects to the Microsoft 365 cloud platform. Use the \'Extract from File\' and \'Convert to File\' nodes to directly manipulate spreadsheet files (.xls, .csv, etc). More info.', + 'This node connects to the Microsoft 365 cloud platform. Use the \'Extract from File\' and \'Convert to File\' nodes to directly manipulate spreadsheet files (.xls, .csv, etc). More info.', name: 'notice', type: 'notice', default: '', diff --git a/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts b/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts index df0fd29515..5bf2a3ce8e 100644 --- a/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts +++ b/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts @@ -1039,7 +1039,7 @@ export class Salesforce implements INodeType { // async getFolders(this: ILoadOptionsFunctions): Promise { // const returnData: INodePropertyOptions[] = []; // const fields = await salesforceApiRequestAllItems.call(this, 'records', 'GET', '/sobjects/folder/describe'); - // console.log(JSON.stringify(fields, undefined, 2)) + // this.logger.debug(JSON.stringify(fields, undefined, 2)) // const qs = { // //ContentFolderItem ContentWorkspace ContentFolder // q: `SELECT Id, Title FROM ContentVersion`, diff --git a/packages/nodes-base/nodes/Webflow/GenericFunctions.ts b/packages/nodes-base/nodes/Webflow/GenericFunctions.ts index 49d02bcf04..590f02a6e9 100644 --- a/packages/nodes-base/nodes/Webflow/GenericFunctions.ts +++ b/packages/nodes-base/nodes/Webflow/GenericFunctions.ts @@ -31,7 +31,6 @@ export async function webflowApiRequest( // Keep support for v1 node if (this.getNode().typeVersion === 1) { - console.log('v1'); const authenticationMethod = this.getNodeParameter('authentication', 0, 'accessToken'); if (authenticationMethod === 'accessToken') { credentialsType = 'webflowApi'; @@ -81,8 +80,6 @@ export async function getSites(this: ILoadOptionsFunctions): Promise diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 6c35d03eb3..7f35e94314 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -1,6 +1,6 @@ { "name": "n8n-workflow", - "version": "1.68.0", + "version": "1.69.0", "description": "Workflow base code of n8n", "main": "dist/index.js", "module": "src/index.ts", diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index e766a4aa21..0a7a5d0379 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2274,6 +2274,7 @@ export interface IWorkflowExecutionDataProcess { * PARTIAL_EXECUTION_VERSION_DEFAULT */ partialExecutionVersion?: string; + dirtyNodeNames?: string[]; } export interface ExecuteWorkflowOptions { diff --git a/packages/workflow/src/errors/expression.error.ts b/packages/workflow/src/errors/expression.error.ts index 32aaae3812..5e56b694f8 100644 --- a/packages/workflow/src/errors/expression.error.ts +++ b/packages/workflow/src/errors/expression.error.ts @@ -30,7 +30,7 @@ export interface ExpressionErrorOptions { */ export class ExpressionError extends ExecutionBaseError { constructor(message: string, options?: ExpressionErrorOptions) { - super(message, { cause: options?.cause }); + super(message, { cause: options?.cause, level: 'warning' }); if (options?.description !== undefined) { this.description = options.description; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca720e72fa..89757c46e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ catalogs: form-data: specifier: 4.0.0 version: 4.0.0 + iconv-lite: + specifier: 0.6.3 + version: 0.6.3 lodash: specifier: 4.17.21 version: 4.17.21 @@ -378,7 +381,7 @@ importers: packages/@n8n/imap: dependencies: iconv-lite: - specifier: 0.6.3 + specifier: 'catalog:' version: 0.6.3 imap: specifier: 0.8.19 @@ -1142,6 +1145,9 @@ importers: form-data: specifier: 'catalog:' version: 4.0.0 + iconv-lite: + specifier: 'catalog:' + version: 0.6.3 lodash: specifier: 'catalog:' version: 4.17.21 @@ -1513,6 +1519,9 @@ importers: vue-router: specifier: catalog:frontend version: 4.4.5(vue@3.5.11(typescript@5.7.2)) + vue-virtual-scroller: + specifier: 2.0.0-beta.8 + version: 2.0.0-beta.8(vue@3.5.11(typescript@5.7.2)) vue3-touch-events: specifier: ^4.1.3 version: 4.1.3 @@ -1675,7 +1684,7 @@ importers: specifier: 9.0.5 version: 9.0.5 iconv-lite: - specifier: 0.6.3 + specifier: 'catalog:' version: 0.6.3 ics: specifier: 2.40.0 @@ -9244,6 +9253,9 @@ packages: resolution: {integrity: sha512-MGZAq0Q3OuRYgZKvlB69z4gLN4G3PvgC4A2zhkCXCXrLD5wm2cCnwNB59xOBVA+srZ0zEes6u+VylcPIkB4SqA==} engines: {node: 6.* || 8.* || >= 10.*} + mitt@2.1.0: + resolution: {integrity: sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==} + mjml-accordion@4.15.3: resolution: {integrity: sha512-LPNVSj1LyUVYT9G1gWwSw3GSuDzDsQCu0tPB2uDsq4VesYNnU6v3iLCQidMiR6azmIt13OEozG700ygAUuA6Ng==} @@ -11004,10 +11016,6 @@ packages: resolution: {integrity: sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - source-map-js@1.0.2: - resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} - engines: {node: '>=0.10.0'} - source-map-js@1.2.0: resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} engines: {node: '>=0.10.0'} @@ -12134,6 +12142,16 @@ packages: peerDependencies: vue: ^3.3.4 + vue-observe-visibility@2.0.0-alpha.1: + resolution: {integrity: sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==} + peerDependencies: + vue: ^3.0.0 + + vue-resize@2.0.0-alpha.1: + resolution: {integrity: sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==} + peerDependencies: + vue: ^3.0.0 + vue-router@4.4.5: resolution: {integrity: sha512-4fKZygS8cH1yCyuabAXGUAsyi1b2/o/OKgu/RUb+znIYOxPRxdkytJEx+0wGcpBE1pX6vUgh5jwWOKRGvuA/7Q==} peerDependencies: @@ -12145,6 +12163,11 @@ packages: peerDependencies: typescript: ^5.7.2 + vue-virtual-scroller@2.0.0-beta.8: + resolution: {integrity: sha512-b8/f5NQ5nIEBRTNi6GcPItE4s7kxNHw2AIHLtDp+2QvqdTjVN0FgONwX9cr53jWRgnu+HRLPaWDOR2JPI5MTfQ==} + peerDependencies: + vue: ^3.2.0 + vue3-touch-events@4.1.3: resolution: {integrity: sha512-uXTclRzn7de1mgiDIZ8N4J/wnWl1vBPLTWr60fqoLXu7ifhDKpl83Q2m9qA20KfEiAy+L4X/xXGc5ptGmdPh4A==} @@ -21272,7 +21295,7 @@ snapshots: form-data: 4.0.0 html-encoding-sniffer: 4.0.0 http-proxy-agent: 7.0.0 - https-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.5 is-potential-custom-element-name: 1.0.1 nwsapi: 2.2.7 parse5: 7.1.2 @@ -22002,6 +22025,8 @@ snapshots: lodash: 4.17.21 pretender: 3.4.7 + mitt@2.1.0: {} + mjml-accordion@4.15.3(encoding@0.1.13): dependencies: '@babel/runtime': 7.24.7 @@ -23988,7 +24013,7 @@ snapshots: dependencies: chokidar: 4.0.1 immutable: 4.2.2 - source-map-js: 1.0.2 + source-map-js: 1.2.1 sax@1.2.4: {} @@ -24270,8 +24295,6 @@ snapshots: smart-buffer: 4.2.0 optional: true - source-map-js@1.0.2: {} - source-map-js@1.2.0: {} source-map-js@1.2.1: {} @@ -25494,6 +25517,14 @@ snapshots: markdown-it: 13.0.2 vue: 3.5.11(typescript@5.7.2) + vue-observe-visibility@2.0.0-alpha.1(vue@3.5.11(typescript@5.7.2)): + dependencies: + vue: 3.5.11(typescript@5.7.2) + + vue-resize@2.0.0-alpha.1(vue@3.5.11(typescript@5.7.2)): + dependencies: + vue: 3.5.11(typescript@5.7.2) + vue-router@4.4.5(vue@3.5.11(typescript@5.7.2)): dependencies: '@vue/devtools-api': 6.6.4 @@ -25506,6 +25537,13 @@ snapshots: semver: 7.6.0 typescript: 5.7.2 + vue-virtual-scroller@2.0.0-beta.8(vue@3.5.11(typescript@5.7.2)): + dependencies: + mitt: 2.1.0 + vue: 3.5.11(typescript@5.7.2) + vue-observe-visibility: 2.0.0-alpha.1(vue@3.5.11(typescript@5.7.2)) + vue-resize: 2.0.0-alpha.1(vue@3.5.11(typescript@5.7.2)) + vue3-touch-events@4.1.3: {} vue@3.5.11(typescript@5.7.2): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8b692007af..306d776861 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,6 +18,7 @@ catalog: fast-glob: 3.2.12 flatted: 3.2.7 form-data: 4.0.0 + iconv-lite: 0.6.3 lodash: 4.17.21 luxon: 3.4.4 nanoid: 3.3.6