From 73f8e72acac05e552f5d295cdb41bc4cb2fdcf94 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 21 Jul 2025 17:18:46 +0100 Subject: [PATCH 001/142] docs: Update issue form (#17518) --- .github/ISSUE_TEMPLATE/01-bug.yml | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/01-bug.yml b/.github/ISSUE_TEMPLATE/01-bug.yml index f7c79f718f0..3d507ed3058 100644 --- a/.github/ISSUE_TEMPLATE/01-bug.yml +++ b/.github/ISSUE_TEMPLATE/01-bug.yml @@ -4,7 +4,13 @@ body: - type: markdown attributes: value: | - Thanks for taking the time to fill out this bug report! + > ⚠️ This form is for reporting bugs only. + > ❌ Please do not use this form for general support, feature requests, or questions. + > 💬 For help and general inquiries, visit our [community support forum](https://community.n8n.io). + > ☁️ If you're experiencing issues with cloud instances not starting or license-related problems, contact [n8n support directly](mailto:help@n8n.io). + --- + Thank you for helping us improve n8n! + To ensure we can address your report efficiently, please fill out all sections in English and provide as much detail as possible. - type: textarea id: description attributes: @@ -32,6 +38,13 @@ body: description: A clear and concise description of what you expected to happen validations: required: true + - type: textarea + id: debug-info + attributes: + label: Debug Info + description: This can be found under Help > About n8n > Copy debug information + validations: + required: true - type: markdown attributes: value: '## Environment' @@ -80,3 +93,13 @@ body: default: 0 validations: required: true + - type: dropdown + id: hosting + attributes: + label: Hosting + options: + - n8n cloud + - self hosted + default: 0 + validations: + required: true From 2708fe81a5323687c59c3d483d6bf3c67464f657 Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Tue, 22 Jul 2025 09:22:35 +0200 Subject: [PATCH 002/142] fix(core): Ignore pairedItem when checking for incorrect output data from a node (#17340) --- .../errors/__tests__/error-reporter.test.ts | 15 +++++++++++++ packages/core/src/errors/error-reporter.ts | 5 ++++- .../src/execution-engine/workflow-execute.ts | 19 ++++++++-------- .../__tests__/is-json-compatible.test.ts | 22 +++++++++++++++++++ packages/core/src/utils/is-json-compatible.ts | 17 ++++++++++---- packages/workflow/src/errors/error.types.ts | 2 ++ 6 files changed, 66 insertions(+), 14 deletions(-) diff --git a/packages/core/src/errors/__tests__/error-reporter.test.ts b/packages/core/src/errors/__tests__/error-reporter.test.ts index cc35d69e887..e5d3f830a6b 100644 --- a/packages/core/src/errors/__tests__/error-reporter.test.ts +++ b/packages/core/src/errors/__tests__/error-reporter.test.ts @@ -194,5 +194,20 @@ describe('ErrorReporter', () => { errorReporter.error(error); expect(logger.error).toHaveBeenCalledWith('Test error', metadata); }); + + it.each([true, undefined])( + 'should log the error when shouldBeLogged is %s', + (shouldBeLogged) => { + error.level = 'error'; + errorReporter.error(error, { shouldBeLogged }); + expect(logger.error).toHaveBeenCalledTimes(1); + }, + ); + + it('should not log the error when shouldBeLogged is false', () => { + error.level = 'error'; + errorReporter.error(error, { shouldBeLogged: false }); + expect(logger.error).toHaveBeenCalledTimes(0); + }); }); }); diff --git a/packages/core/src/errors/error-reporter.ts b/packages/core/src/errors/error-reporter.ts index 79a12ef4390..5d8e85423ee 100644 --- a/packages/core/src/errors/error-reporter.ts +++ b/packages/core/src/errors/error-reporter.ts @@ -60,7 +60,10 @@ export class ErrorReporter { meta = e.extra; } const msg = [e.message + context, stack].join(''); - this.logger.error(msg, meta); + // Default to logging the error if option is not specified + if (options?.shouldBeLogged ?? true) { + this.logger.error(msg, meta); + } e = e.cause as Error; } while (e); } diff --git a/packages/core/src/execution-engine/workflow-execute.ts b/packages/core/src/execution-engine/workflow-execute.ts index bbe842bd705..3fa64592eae 100644 --- a/packages/core/src/execution-engine/workflow-execute.ts +++ b/packages/core/src/execution-engine/workflow-execute.ts @@ -2,6 +2,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ +import { GlobalConfig } from '@n8n/config'; import { TOOL_EXECUTOR_NODE_NAME } from '@n8n/constants'; import { Container } from '@n8n/di'; import * as assert from 'assert/strict'; @@ -1211,13 +1212,13 @@ export class WorkflowExecute { : await nodeType.execute.call(context); } - // If data is not json compatible then log it as incorrect output - // Does not block the execution from continuing - const jsonCompatibleResult = isJsonCompatible(data); - if (!jsonCompatibleResult.isValid) { - Container.get(ErrorReporter).error( - new UnexpectedError('node execution output incorrect data'), - { + if (Container.get(GlobalConfig).sentry.backendDsn) { + // If data is not json compatible then log it as incorrect output + // Does not block the execution from continuing + const jsonCompatibleResult = isJsonCompatible(data, new Set(['pairedItem'])); + if (!jsonCompatibleResult.isValid) { + Container.get(ErrorReporter).error('node execution returned incorrect data', { + shouldBeLogged: false, extra: { nodeName: node.name, nodeType: node.type, @@ -1228,8 +1229,8 @@ export class WorkflowExecute { errorPath: jsonCompatibleResult.errorPath, errorMessage: jsonCompatibleResult.errorMessage, }, - }, - ); + }); + } } const closeFunctionsResults = await Promise.allSettled( diff --git a/packages/core/src/utils/__tests__/is-json-compatible.test.ts b/packages/core/src/utils/__tests__/is-json-compatible.test.ts index 7e85ee1e41b..23c5e787e61 100644 --- a/packages/core/src/utils/__tests__/is-json-compatible.test.ts +++ b/packages/core/src/utils/__tests__/is-json-compatible.test.ts @@ -27,6 +27,18 @@ describe('isJsonCompatible', () => { errorPath: 'value.date', errorMessage: 'has non-plain prototype (Date)', }, + { + name: 'a RegExp', + value: { regexp: new RegExp('') }, + errorPath: 'value.regexp', + errorMessage: 'has non-plain prototype (RegExp)', + }, + { + name: 'a Buffer', + value: { buffer: Buffer.from('') }, + errorPath: 'value.buffer', + errorMessage: 'has non-plain prototype (Buffer)', + }, { name: 'a function', value: { fn: () => {} }, @@ -131,4 +143,14 @@ describe('isJsonCompatible', () => { expect(result.isValid).toBe(true); }); + + test('skip keys that are in the keysToIgnore set', () => { + const value = { + invalidObject: { invalidBecauseUndefined: undefined }, + validObject: { key: 'value' }, + }; + const result = isJsonCompatible(value, new Set(['invalidObject'])); + + expect(result.isValid).toBe(true); + }); }); diff --git a/packages/core/src/utils/is-json-compatible.ts b/packages/core/src/utils/is-json-compatible.ts index 862b075fb8c..8f3149c4055 100644 --- a/packages/core/src/utils/is-json-compatible.ts +++ b/packages/core/src/utils/is-json-compatible.ts @@ -2,6 +2,7 @@ const check = ( val: unknown, path = 'value', stack: Set = new Set(), + keysToIgnore: Set = new Set(), ): { isValid: true } | { isValid: false; errorPath: string; errorMessage: string } => { const type = typeof val; @@ -38,7 +39,7 @@ const check = ( } stack.add(val); for (let i = 0; i < val.length; i++) { - const result = check(val[i], `${path}[${i}]`, stack); + const result = check(val[i], `${path}[${i}]`, stack, keysToIgnore); if (!result.isValid) return result; } stack.delete(val); @@ -74,8 +75,12 @@ const check = ( }; } + if (keysToIgnore.has(key)) { + continue; + } + const subVal = (val as Record)[key]; - const result = check(subVal, `${path}.${key}`, stack); + const result = check(subVal, `${path}.${key}`, stack, keysToIgnore); if (!result.isValid) return result; } stack.delete(val); @@ -92,14 +97,18 @@ const check = ( /** * This function checks if a value matches JSON data type restrictions. * @param value + * @param keysToIgnore - Set of keys to ignore for objects * @returns boolean */ -export function isJsonCompatible(value: unknown): +export function isJsonCompatible( + value: unknown, + keysToIgnore: Set = new Set(), +): | { isValid: true } | { isValid: false; errorPath: string; errorMessage: string; } { - return check(value); + return check(value, undefined, undefined, keysToIgnore); } diff --git a/packages/workflow/src/errors/error.types.ts b/packages/workflow/src/errors/error.types.ts index 54435298ec6..7cbcc4ccf94 100644 --- a/packages/workflow/src/errors/error.types.ts +++ b/packages/workflow/src/errors/error.types.ts @@ -7,6 +7,8 @@ export type ErrorTags = NonNullable; export type ReportingOptions = { /** Whether the error should be reported to Sentry */ shouldReport?: boolean; + /** Whether the error log should be logged (default to true) */ + shouldBeLogged?: boolean; level?: ErrorLevel; tags?: ErrorTags; extra?: Event['extra']; From 0db24ce71b671f6311fc47ac9553466d34c46ba8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Tue, 22 Jul 2025 09:24:55 +0200 Subject: [PATCH 003/142] fix(editor): Remove inline script and style from index.html (#17531) Co-authored-by: Csaba Tuncsik Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- packages/frontend/editor-ui/index.html | 5 ++- .../editor-ui/public/static/posthog.init.js | 41 +++++++++++++++++++ .../public/static/prefers-color-scheme.css | 5 +++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 packages/frontend/editor-ui/public/static/posthog.init.js create mode 100644 packages/frontend/editor-ui/public/static/prefers-color-scheme.css diff --git a/packages/frontend/editor-ui/index.html b/packages/frontend/editor-ui/index.html index bc45b6099c8..27dfc272f48 100644 --- a/packages/frontend/editor-ui/index.html +++ b/packages/frontend/editor-ui/index.html @@ -5,9 +5,10 @@ - + %CONFIG_SCRIPT% - + + n8n.io - Workflow Automation diff --git a/packages/frontend/editor-ui/public/static/posthog.init.js b/packages/frontend/editor-ui/public/static/posthog.init.js new file mode 100644 index 00000000000..8e7eb1f35c7 --- /dev/null +++ b/packages/frontend/editor-ui/public/static/posthog.init.js @@ -0,0 +1,41 @@ +!(function (t, e) { + var o, n, p, r; + e.__SV || + ((window.posthog = e), + (e._i = []), + (e.init = function (i, s, a) { + function g(t, e) { + var o = e.split('.'); + 2 == o.length && ((t = t[o[0]]), (e = o[1])), + (t[e] = function () { + t.push([e].concat(Array.prototype.slice.call(arguments, 0))); + }); + } + ((p = t.createElement('script')).type = 'text/javascript'), + (p.async = !0), + (p.src = s.api_host + '/static/array.js'), + (r = t.getElementsByTagName('script')[0]).parentNode.insertBefore(p, r); + var u = e; + for ( + void 0 !== a ? (u = e[a] = []) : (a = 'posthog'), + u.people = u.people || [], + u.toString = function (t) { + var e = 'posthog'; + return 'posthog' !== a && (e += '.' + a), t || (e += ' (stub)'), e; + }, + u.people.toString = function () { + return u.toString(1) + '.people (stub)'; + }, + o = + 'capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled getFeatureFlag onFeatureFlags reloadFeatureFlags'.split( + ' ', + ), + n = 0; + n < o.length; + n++ + ) + g(u, o[n]); + e._i.push([i, s, a]); + }), + (e.__SV = 1)); +})(document, window.posthog || []); diff --git a/packages/frontend/editor-ui/public/static/prefers-color-scheme.css b/packages/frontend/editor-ui/public/static/prefers-color-scheme.css new file mode 100644 index 00000000000..e1ef59a157d --- /dev/null +++ b/packages/frontend/editor-ui/public/static/prefers-color-scheme.css @@ -0,0 +1,5 @@ +@media (prefers-color-scheme: dark) { + body { + background-color: rgb(45, 46, 46); + } +} From e8056515c31d893d9dcd466030814c43c28a61cc Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Tue, 22 Jul 2025 09:45:53 +0200 Subject: [PATCH 004/142] chore(core): Provide details on access token exchange error (#17506) --- .../@n8n/client-oauth2/src/client-oauth2.ts | 10 ++++++-- .../oauth2-credential.controller.test.ts | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/@n8n/client-oauth2/src/client-oauth2.ts b/packages/@n8n/client-oauth2/src/client-oauth2.ts index 985b16c20ab..5bbbde43552 100644 --- a/packages/@n8n/client-oauth2/src/client-oauth2.ts +++ b/packages/@n8n/client-oauth2/src/client-oauth2.ts @@ -42,8 +42,9 @@ export class ResponseError extends Error { readonly status: number, readonly body: unknown, readonly code = 'ESTATUS', + readonly message = `HTTP status ${status}`, ) { - super(`HTTP status ${status}`); + super(message); } } @@ -133,6 +134,11 @@ export class ClientOAuth2 { return qs.parse(body) as T; } - throw new Error(`Unsupported content type: ${contentType}`); + throw new ResponseError( + response.status, + body, + undefined, + `Unsupported content type: ${contentType}`, + ); } } diff --git a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts index b9ba4c9becc..76fe241232b 100644 --- a/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/oauth2-credential.controller.test.ts @@ -243,6 +243,30 @@ describe('OAuth2CredentialController', () => { }); }); + it('should render the error page when code exchange fails, and the server responses with html', async () => { + credentialsRepository.findOneBy.mockResolvedValueOnce(credential); + credentialsHelper.getDecrypted.mockResolvedValueOnce({ csrfSecret }); + jest.spyOn(Csrf.prototype, 'verify').mockReturnValueOnce(true); + nock('https://example.domain') + .post( + '/token', + 'code=code&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5678%2Frest%2Foauth2-credential%2Fcallback', + ) + .reply(403, 'Code could not be exchanged', { + 'Content-Type': 'text/html', + }); + + await controller.handleCallback(req, res); + + expect(externalHooks.run).toHaveBeenCalled(); + expect(res.render).toHaveBeenCalledWith('oauth-error-callback', { + error: { + message: 'Unsupported content type: text/html', + reason: '"Code could not be exchanged"', + }, + }); + }); + it('should exchange the code for a valid token, and save it to DB', async () => { credentialsRepository.findOneBy.mockResolvedValueOnce(credential); credentialsHelper.getDecrypted.mockResolvedValueOnce({ csrfSecret }); From 50b83add83846b72c9fdb2be9558c8a60e2c6488 Mon Sep 17 00:00:00 2001 From: Marc Littlemore Date: Tue, 22 Jul 2025 08:55:46 +0100 Subject: [PATCH 005/142] chore: Update license SDK to 2.23.0 (#17519) --- packages/cli/package.json | 4 +-- pnpm-lock.yaml | 61 ++++++++++++++++++++++----------------- 2 files changed, 36 insertions(+), 29 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index ad4f2313098..3067640a1e1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -89,6 +89,7 @@ "@n8n/ai-workflow-builder": "workspace:*", "@n8n/api-types": "workspace:*", "@n8n/backend-common": "workspace:^", + "@n8n/backend-test-utils": "workspace:^", "@n8n/client-oauth2": "workspace:*", "@n8n/config": "workspace:*", "@n8n/constants": "workspace:^", @@ -96,14 +97,13 @@ "@n8n/decorators": "workspace:*", "@n8n/di": "workspace:*", "@n8n/errors": "workspace:*", - "@n8n/backend-test-utils": "workspace:^", "@n8n/localtunnel": "3.0.0", "@n8n/n8n-nodes-langchain": "workspace:*", "@n8n/permissions": "workspace:*", "@n8n/task-runner": "workspace:*", "@n8n/typeorm": "catalog:", "@n8n_io/ai-assistant-sdk": "catalog:", - "@n8n_io/license-sdk": "2.22.0", + "@n8n_io/license-sdk": "2.23.0", "@rudderstack/rudder-sdk-node": "2.1.4", "@sentry/node": "catalog:", "aws4": "1.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d031341d28e..f8ce9ad3569 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1343,8 +1343,8 @@ importers: specifier: 'catalog:' version: 1.15.0 '@n8n_io/license-sdk': - specifier: 2.22.0 - version: 2.22.0 + specifier: 2.23.0 + version: 2.23.0 '@rudderstack/rudder-sdk-node': specifier: 2.1.4 version: 2.1.4(tslib@2.8.1) @@ -5569,8 +5569,8 @@ packages: resolution: {integrity: sha512-M/bNnxyVGxwLGU/mzQrZOkZK4NkR9x8cUMZHfVJlv1z6YTlHX56BYH+0jSlb2c15DEwPkku9l0RFVLTTt0ExQQ==} engines: {node: '>=20.15', pnpm: '>=8.14'} - '@n8n_io/license-sdk@2.22.0': - resolution: {integrity: sha512-dysK0bzZXjgBmtDvPU+ZIIcwEeGoQgG4tZAH8E0A1Rs265U7FLe8eg9wyvwLa3RJ4T+qmZrMxR/WSqqtAlCPaQ==} + '@n8n_io/license-sdk@2.23.0': + resolution: {integrity: sha512-WsABHT9yDgz672It1T/B9jfl3EDcCQ7b68HaiB2q0k5u2vIKyDa9HYQQUlPbYoqhzj+kaEpaTVcQt734AvdxbQ==} engines: {node: '>=18.12.1'} '@n8n_io/riot-tmpl@4.0.1': @@ -18315,7 +18315,7 @@ snapshots: '@currents/commit-info': 1.0.1-beta.0 async-retry: 1.3.3 axios: 1.10.0(debug@4.4.1) - axios-retry: 4.5.0(axios@1.10.0(debug@4.4.1)) + axios-retry: 4.5.0(axios@1.10.0) c12: 1.11.2(magicast@0.3.5) chalk: 4.1.2 commander: 12.1.0 @@ -19655,7 +19655,7 @@ snapshots: '@n8n_io/ai-assistant-sdk@1.15.0': {} - '@n8n_io/license-sdk@2.22.0': + '@n8n_io/license-sdk@2.23.0': dependencies: crypto-js: 4.2.0 node-machine-id: 1.1.12 @@ -22941,14 +22941,9 @@ snapshots: axe-core@4.7.2: {} - axios-retry@4.5.0(axios@1.10.0(debug@4.4.1)): - dependencies: - axios: 1.10.0(debug@4.4.1) - is-retry-allowed: 2.2.0 - axios-retry@4.5.0(axios@1.10.0): dependencies: - axios: 1.10.0(debug@4.4.1) + axios: 1.10.0 is-retry-allowed: 2.2.0 axios-retry@4.5.0(axios@1.8.3): @@ -22956,6 +22951,14 @@ snapshots: axios: 1.8.3 is-retry-allowed: 2.2.0 + axios@1.10.0: + dependencies: + follow-redirects: 1.15.9(debug@4.4.0) + form-data: 4.0.2 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axios@1.10.0(debug@4.3.6): dependencies: follow-redirects: 1.15.9(debug@4.3.6) @@ -22974,7 +22977,7 @@ snapshots: axios@1.10.0(debug@4.4.1): dependencies: - follow-redirects: 1.15.9(debug@4.3.6) + follow-redirects: 1.15.9(debug@4.4.1) form-data: 4.0.2 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -22982,7 +22985,7 @@ snapshots: axios@1.8.3: dependencies: - follow-redirects: 1.15.9(debug@4.3.6) + follow-redirects: 1.15.9(debug@4.4.0) form-data: 4.0.2 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -23311,7 +23314,7 @@ snapshots: bundlemon@3.1.0(typescript@5.8.3): dependencies: - axios: 1.10.0(debug@4.4.1) + axios: 1.10.0 axios-retry: 4.5.0(axios@1.10.0) brotli-size: 4.0.0 bundlemon-utils: 2.0.1 @@ -24977,7 +24980,7 @@ snapshots: eslint-import-resolver-node@0.3.9: dependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) is-core-module: 2.16.1 resolve: 1.22.10 transitivePeerDependencies: @@ -25001,7 +25004,7 @@ snapshots: eslint-module-utils@2.12.1(@typescript-eslint/parser@8.35.0(eslint@9.29.0(jiti@1.21.7))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.3)(eslint@9.29.0(jiti@1.21.7)): dependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) optionalDependencies: '@typescript-eslint/parser': 8.35.0(eslint@9.29.0(jiti@1.21.7))(typescript@5.8.3) eslint: 9.29.0(jiti@1.21.7) @@ -25040,7 +25043,7 @@ snapshots: array.prototype.findlastindex: 1.2.6 array.prototype.flat: 1.3.3 array.prototype.flatmap: 1.3.3 - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) doctrine: 2.1.0 eslint: 9.29.0(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 @@ -25614,6 +25617,10 @@ snapshots: optionalDependencies: debug: 4.4.0 + follow-redirects@1.15.9(debug@4.4.1): + optionalDependencies: + debug: 4.4.1(supports-color@8.1.1) + for-each@0.3.3: dependencies: is-callable: 1.2.7 @@ -25992,7 +25999,7 @@ snapshots: array-parallel: 0.1.3 array-series: 0.1.5 cross-spawn: 7.0.6 - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -26313,7 +26320,7 @@ snapshots: '@types/debug': 4.1.12 '@types/node': 20.19.1 '@types/tough-cookie': 4.0.5 - axios: 1.10.0(debug@4.4.1) + axios: 1.10.0 camelcase: 6.3.0 debug: 4.4.1(supports-color@8.1.1) dotenv: 16.5.0 @@ -26392,7 +26399,7 @@ snapshots: infisical-node@1.3.0: dependencies: - axios: 1.10.0(debug@4.4.1) + axios: 1.10.0 dotenv: 16.3.1 tweetnacl: 1.0.3 tweetnacl-util: 0.15.1 @@ -27564,7 +27571,7 @@ snapshots: '@langchain/groq': 0.2.3(@langchain/core@0.3.61(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.29.0(@opentelemetry/api@1.9.0))(openai@5.8.1(ws@8.18.2)(zod@3.25.67)))(encoding@0.1.13) '@langchain/mistralai': 0.2.1(@langchain/core@0.3.61(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.29.0(@opentelemetry/api@1.9.0))(openai@5.8.1(ws@8.18.2)(zod@3.25.67)))(zod@3.25.67) '@langchain/ollama': 0.2.3(@langchain/core@0.3.61(@opentelemetry/api@1.9.0)(@opentelemetry/sdk-trace-base@1.29.0(@opentelemetry/api@1.9.0))(openai@5.8.1(ws@8.18.2)(zod@3.25.67))) - axios: 1.10.0(debug@4.4.1) + axios: 1.10.0 cheerio: 1.0.0 handlebars: 4.7.8 transitivePeerDependencies: @@ -29308,7 +29315,7 @@ snapshots: pdf-parse@1.1.1: dependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) node-ensure: 0.0.0 transitivePeerDependencies: - supports-color @@ -29567,7 +29574,7 @@ snapshots: posthog-node@3.2.1: dependencies: - axios: 1.10.0(debug@4.4.1) + axios: 1.10.0 rusha: 0.8.14 transitivePeerDependencies: - debug @@ -30253,7 +30260,7 @@ snapshots: retry-axios@2.6.0(axios@1.10.0(debug@4.4.1)): dependencies: - axios: 1.10.0(debug@4.4.1) + axios: 1.10.0 retry-request@7.0.2(encoding@0.1.13): dependencies: @@ -30278,7 +30285,7 @@ snapshots: rhea@1.0.24: dependencies: - debug: 3.2.7(supports-color@5.5.0) + debug: 3.2.7(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -30758,7 +30765,7 @@ snapshots: asn1.js: 5.4.1 asn1.js-rfc2560: 5.0.1(asn1.js@5.4.1) asn1.js-rfc5280: 3.0.0 - axios: 1.10.0(debug@4.4.1) + axios: 1.10.0 big-integer: 1.6.52 bignumber.js: 9.1.2 binascii: 0.0.2 From d6ac924b3b7d2205cbcc0e5edc7ad407f4fe2a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Tue, 22 Jul 2025 10:04:22 +0200 Subject: [PATCH 006/142] fix(editor): Fix error when there is no path back to referenced node (#16059) Co-authored-by: Csaba Tuncsik --- cypress/e2e/14-mapping.cy.ts | 4 +- .../e2e/27-two-factor-authentication.cy.ts | 2 +- cypress/e2e/33-settings-personal.cy.ts | 2 +- cypress/pages/ndv.ts | 2 +- cypress/support/commands.ts | 2 +- .../workflow/src/errors/expression.error.ts | 19 + packages/workflow/src/workflow-data-proxy.ts | 117 ++- packages/workflow/src/workflow.ts | 58 ++ .../test/paired-item-path-detection.test.ts | 757 ++++++++++++++++++ .../workflow/test/workflow-data-proxy.test.ts | 12 +- packages/workflow/test/workflow.test.ts | 342 ++++++++ 11 files changed, 1265 insertions(+), 52 deletions(-) create mode 100644 packages/workflow/test/paired-item-path-detection.test.ts diff --git a/cypress/e2e/14-mapping.cy.ts b/cypress/e2e/14-mapping.cy.ts index b8d263941fa..3af70bc2708 100644 --- a/cypress/e2e/14-mapping.cy.ts +++ b/cypress/e2e/14-mapping.cy.ts @@ -1,10 +1,10 @@ +import { WorkflowPage, NDV } from '../pages'; +import { getVisibleSelect } from '../utils'; import { MANUAL_TRIGGER_NODE_NAME, MANUAL_TRIGGER_NODE_DISPLAY_NAME, SCHEDULE_TRIGGER_NODE_NAME, } from './../constants'; -import { WorkflowPage, NDV } from '../pages'; -import { getVisibleSelect } from '../utils'; const workflowPage = new WorkflowPage(); const ndv = new NDV(); diff --git a/cypress/e2e/27-two-factor-authentication.cy.ts b/cypress/e2e/27-two-factor-authentication.cy.ts index 2201e652851..877345ccc10 100644 --- a/cypress/e2e/27-two-factor-authentication.cy.ts +++ b/cypress/e2e/27-two-factor-authentication.cy.ts @@ -1,11 +1,11 @@ import generateOTPToken from 'cypress-otp'; -import { MainSidebar } from './../pages/sidebar/main-sidebar'; import { INSTANCE_OWNER, INSTANCE_ADMIN, BACKEND_BASE_URL } from '../constants'; import { SigninPage } from '../pages'; import { MfaLoginPage } from '../pages/mfa-login'; import { successToast } from '../pages/notifications'; import { PersonalSettingsPage } from '../pages/settings-personal'; +import { MainSidebar } from './../pages/sidebar/main-sidebar'; const MFA_SECRET = 'KVKFKRCPNZQUYMLXOVYDSQKJKZDTSRLD'; diff --git a/cypress/e2e/33-settings-personal.cy.ts b/cypress/e2e/33-settings-personal.cy.ts index 6b5cc946876..183655cbe8b 100644 --- a/cypress/e2e/33-settings-personal.cy.ts +++ b/cypress/e2e/33-settings-personal.cy.ts @@ -35,7 +35,7 @@ describe('Personal Settings', () => { successToast().find('.el-notification__closeBtn').click(); }); }); - // eslint-disable-next-line n8n-local-rules/no-skipped-tests + it('not allow malicious values for personal data', () => { cy.visit('/settings/personal'); INVALID_NAMES.forEach((name) => { diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index caa2ead7374..5b672d83ebc 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -305,7 +305,7 @@ export class NDV extends BasePage { this.actions.typeIntoParameterInput(fieldName, invalidExpression ?? "{{ $('unknown')", { parseSpecialCharSequences: false, }); - this.actions.validateExpressionPreview(fieldName, "node doesn't exist"); + this.actions.validateExpressionPreview(fieldName, 'No path back to node'); }, openSettings: () => { this.getters.nodeSettingsTab().click(); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 91e9f540c7f..f4237c8adc7 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -149,7 +149,7 @@ Cypress.Commands.add('grantBrowserPermissions', (...permissions: string[]) => { }); Cypress.Commands.add('readClipboard', () => - cy.window().then((win) => win.navigator.clipboard.readText()), + cy.window().then(async (win) => await win.navigator.clipboard.readText()), ); Cypress.Commands.add('paste', { prevSubject: true }, (selector, pastePayload) => { diff --git a/packages/workflow/src/errors/expression.error.ts b/packages/workflow/src/errors/expression.error.ts index 8b8bb6ef869..9e97418e92e 100644 --- a/packages/workflow/src/errors/expression.error.ts +++ b/packages/workflow/src/errors/expression.error.ts @@ -28,6 +28,25 @@ export interface ExpressionErrorOptions { /** * Class for instantiating an expression error */ +// Expression error constants +export const EXPRESSION_ERROR_MESSAGES = { + NODE_NOT_FOUND: 'Error finding the referenced node', + NODE_REFERENCE_TEMPLATE: + 'Make sure the node you referenced is spelled correctly and is a parent of this node', + NO_EXECUTION_DATA: 'No execution data available', +} as const; + +export const EXPRESSION_ERROR_TYPES = { + PAIRED_ITEM_NO_CONNECTION: 'paired_item_no_connection', +} as const; + +export const EXPRESSION_DESCRIPTION_KEYS = { + NODE_NOT_FOUND: 'nodeNotFound', + NO_NODE_EXECUTION_DATA: 'noNodeExecutionData', + PAIRED_ITEM_NO_CONNECTION: 'pairedItemNoConnection', + PAIRED_ITEM_NO_CONNECTION_CODE_NODE: 'pairedItemNoConnectionCodeNode', +} as const; + export class ExpressionError extends ExecutionBaseError { constructor(message: string, options?: ExpressionErrorOptions) { super(message, { cause: options?.cause, level: 'warning' }); diff --git a/packages/workflow/src/workflow-data-proxy.ts b/packages/workflow/src/workflow-data-proxy.ts index 27872819325..554dace0d6f 100644 --- a/packages/workflow/src/workflow-data-proxy.ts +++ b/packages/workflow/src/workflow-data-proxy.ts @@ -8,7 +8,13 @@ import { DateTime, Duration, Interval, Settings } from 'luxon'; import { augmentArray, augmentObject } from './augment-object'; import { AGENT_LANGCHAIN_NODE_TYPE, SCRIPTING_NODE_TYPES } from './constants'; import { ApplicationError } from './errors/application.error'; -import { ExpressionError, type ExpressionErrorOptions } from './errors/expression.error'; +import { + ExpressionError, + type ExpressionErrorOptions, + EXPRESSION_ERROR_MESSAGES, + EXPRESSION_ERROR_TYPES, + EXPRESSION_DESCRIPTION_KEYS, +} from './errors/expression.error'; import { getGlobalState } from './global-state'; import { NodeConnectionTypes } from './interfaces'; import type { @@ -390,11 +396,13 @@ export class WorkflowDataProxy { } if (!that.workflow.getNode(nodeName)) { - throw new ExpressionError("Referenced node doesn't exist", { + throw new ExpressionError(EXPRESSION_ERROR_MESSAGES.NODE_NOT_FOUND, { + messageTemplate: EXPRESSION_ERROR_MESSAGES.NODE_REFERENCE_TEMPLATE, runIndex: that.runIndex, itemIndex: that.itemIndex, nodeCause: nodeName, - descriptionKey: 'nodeNotFound', + descriptionKey: EXPRESSION_DESCRIPTION_KEYS.NODE_NOT_FOUND, + type: EXPRESSION_ERROR_TYPES.PAIRED_ITEM_NO_CONNECTION, }); } @@ -402,11 +410,12 @@ export class WorkflowDataProxy { !that.runExecutionData.resultData.runData.hasOwnProperty(nodeName) && !getPinDataIfManualExecution(that.workflow, nodeName, that.mode) ) { - throw new ExpressionError('Referenced node is unexecuted', { + throw new ExpressionError(EXPRESSION_ERROR_MESSAGES.NODE_NOT_FOUND, { + messageTemplate: EXPRESSION_ERROR_MESSAGES.NODE_REFERENCE_TEMPLATE, runIndex: that.runIndex, itemIndex: that.itemIndex, - type: 'no_node_execution_data', - descriptionKey: 'noNodeExecutionData', + type: EXPRESSION_ERROR_TYPES.PAIRED_ITEM_NO_CONNECTION, + descriptionKey: EXPRESSION_DESCRIPTION_KEYS.NO_NODE_EXECUTION_DATA, nodeCause: nodeName, }); } @@ -496,11 +505,16 @@ export class WorkflowDataProxy { name = name.toString(); if (!node) { - throw new ExpressionError("Referenced node doesn't exist", { + throw new ExpressionError(EXPRESSION_ERROR_MESSAGES.NODE_NOT_FOUND, { + messageTemplate: EXPRESSION_ERROR_MESSAGES.NODE_REFERENCE_TEMPLATE, + functionality: 'pairedItem', + descriptionKey: isScriptingNode(nodeName, that.workflow) + ? EXPRESSION_DESCRIPTION_KEYS.PAIRED_ITEM_NO_CONNECTION_CODE_NODE + : EXPRESSION_DESCRIPTION_KEYS.PAIRED_ITEM_NO_CONNECTION, + type: EXPRESSION_ERROR_TYPES.PAIRED_ITEM_NO_CONNECTION, + nodeCause: nodeName, runIndex: that.runIndex, itemIndex: that.itemIndex, - nodeCause: nodeName, - descriptionKey: 'nodeNotFound', }); } @@ -516,7 +530,7 @@ export class WorkflowDataProxy { if (executionData.length === 0) { if (that.workflow.getParentNodes(nodeName).length === 0) { - throw new ExpressionError('No execution data available', { + throw new ExpressionError(EXPRESSION_ERROR_MESSAGES.NO_EXECUTION_DATA, { messageTemplate: 'No execution data available to expression under ‘%%PARAMETER%%’', descriptionKey: 'noInputConnection', @@ -527,7 +541,7 @@ export class WorkflowDataProxy { }); } - throw new ExpressionError('No execution data available', { + throw new ExpressionError(EXPRESSION_ERROR_MESSAGES.NO_EXECUTION_DATA, { runIndex: that.runIndex, itemIndex: that.itemIndex, type: 'no_execution_data', @@ -693,11 +707,16 @@ export class WorkflowDataProxy { const nodeName = name.toString(); if (that.workflow.getNode(nodeName) === null) { - throw new ExpressionError("Referenced node doesn't exist", { + throw new ExpressionError(EXPRESSION_ERROR_MESSAGES.NODE_NOT_FOUND, { + messageTemplate: EXPRESSION_ERROR_MESSAGES.NODE_REFERENCE_TEMPLATE, + functionality: 'pairedItem', + descriptionKey: isScriptingNode(nodeName, that.workflow) + ? EXPRESSION_DESCRIPTION_KEYS.PAIRED_ITEM_NO_CONNECTION_CODE_NODE + : EXPRESSION_DESCRIPTION_KEYS.PAIRED_ITEM_NO_CONNECTION, + type: EXPRESSION_ERROR_TYPES.PAIRED_ITEM_NO_CONNECTION, + nodeCause: nodeName, runIndex: that.runIndex, itemIndex: that.itemIndex, - nodeCause: nodeName, - descriptionKey: 'nodeNotFound', }); } @@ -814,14 +833,14 @@ export class WorkflowDataProxy { }); }; - const createNoConnectionError = (nodeCause: string) => { - return createExpressionError('Invalid expression', { - messageTemplate: 'No path back to referenced node', + const createNodeReferenceError = (nodeCause: string) => { + return createExpressionError(EXPRESSION_ERROR_MESSAGES.NODE_NOT_FOUND, { + messageTemplate: EXPRESSION_ERROR_MESSAGES.NODE_REFERENCE_TEMPLATE, functionality: 'pairedItem', descriptionKey: isScriptingNode(nodeCause, that.workflow) - ? 'pairedItemNoConnectionCodeNode' - : 'pairedItemNoConnection', - type: 'paired_item_no_connection', + ? EXPRESSION_DESCRIPTION_KEYS.PAIRED_ITEM_NO_CONNECTION_CODE_NODE + : EXPRESSION_DESCRIPTION_KEYS.PAIRED_ITEM_NO_CONNECTION, + type: EXPRESSION_ERROR_TYPES.PAIRED_ITEM_NO_CONNECTION, moreInfoLink: true, nodeCause, }); @@ -990,7 +1009,7 @@ export class WorkflowDataProxy { const matchedItems = results.filter((result) => result.ok).map((result) => result.result); if (matchedItems.length === 0) { - if (sourceArray.length === 0) throw createNoConnectionError(destinationNodeName); + if (sourceArray.length === 0) throw createNodeReferenceError(destinationNodeName); throw createBranchNotFoundError(sourceData.previousNode, pairedItem.item, nodeBeforeLast); } @@ -1031,7 +1050,7 @@ export class WorkflowDataProxy { inputData?.[NodeConnectionTypes.AiTool]?.[0]?.[itemIndex].json; if (!placeholdersDataInputData) { - throw new ExpressionError('No execution data available', { + throw new ExpressionError(EXPRESSION_ERROR_MESSAGES.NO_EXECUTION_DATA, { runIndex, itemIndex, type: 'no_execution_data', @@ -1053,12 +1072,7 @@ export class WorkflowDataProxy { const referencedNode = that.workflow.getNode(nodeName); if (referencedNode === null) { - throw createExpressionError("Referenced node doesn't exist", { - runIndex: that.runIndex, - itemIndex: that.itemIndex, - nodeCause: nodeName, - descriptionKey: 'nodeNotFound', - }); + throw createNodeReferenceError(nodeName); } const ensureNodeExecutionData = () => { @@ -1066,13 +1080,26 @@ export class WorkflowDataProxy { !that?.runExecutionData?.resultData?.runData.hasOwnProperty(nodeName) && !getPinDataIfManualExecution(that.workflow, nodeName, that.mode) ) { - throw createExpressionError('Referenced node is unexecuted', { - runIndex: that.runIndex, - itemIndex: that.itemIndex, - type: 'no_node_execution_data', - descriptionKey: 'noNodeExecutionData', - nodeCause: nodeName, - }); + throw createNodeReferenceError(nodeName); + } + }; + + const ensureValidPath = () => { + // Check path before execution data + const referencedNode = that.workflow.getNode(nodeName); + if (!referencedNode) { + throw createNodeReferenceError(nodeName); + } + + const activeNode = that.workflow.getNode(that.activeNodeName); + let contextNode = that.contextNodeName; + if (activeNode) { + const parentMainInputNode = that.workflow.getParentMainInputNode(activeNode); + contextNode = parentMainInputNode.name ?? contextNode; + } + + if (!that.workflow.hasPath(nodeName, contextNode)) { + throw createNodeReferenceError(nodeName); } }; @@ -1108,7 +1135,13 @@ export class WorkflowDataProxy { property === PAIRED_ITEM_METHOD.ITEM ) { // Before resolving the pairedItem make sure that the requested node comes in the - // graph before the current one + // graph before the current one or exists in the workflow + const referencedNode = that.workflow.getNode(nodeName); + if (!referencedNode) { + // Node doesn't exist in the workflow (could be trimmed manual execution) + throw createNodeReferenceError(nodeName); + } + const activeNode = that.workflow.getNode(that.activeNodeName); let contextNode = that.contextNodeName; @@ -1116,9 +1149,10 @@ export class WorkflowDataProxy { const parentMainInputNode = that.workflow.getParentMainInputNode(activeNode); contextNode = parentMainInputNode.name ?? contextNode; } - const parentNodes = that.workflow.getParentNodes(contextNode); - if (!parentNodes.includes(nodeName)) { - throw createNoConnectionError(nodeName); + + // Use bidirectional path checking to handle AI/tool nodes properly + if (!that.workflow.hasPath(nodeName, contextNode)) { + throw createNodeReferenceError(nodeName); } ensureNodeExecutionData(); @@ -1199,6 +1233,7 @@ export class WorkflowDataProxy { } if (property === 'first') { + ensureValidPath(); ensureNodeExecutionData(); return (branchIndex?: number, runIndex?: number) => { branchIndex = @@ -1217,6 +1252,7 @@ export class WorkflowDataProxy { }; } if (property === 'last') { + ensureValidPath(); ensureNodeExecutionData(); return (branchIndex?: number, runIndex?: number) => { branchIndex = @@ -1238,6 +1274,7 @@ export class WorkflowDataProxy { }; } if (property === 'all') { + ensureValidPath(); ensureNodeExecutionData(); return (branchIndex?: number, runIndex?: number) => { branchIndex = @@ -1276,7 +1313,7 @@ export class WorkflowDataProxy { if (property === 'isProxy') return true; if (that.connectionInputData.length === 0) { - throw createExpressionError('No execution data available', { + throw createExpressionError(EXPRESSION_ERROR_MESSAGES.NO_EXECUTION_DATA, { runIndex: that.runIndex, itemIndex: that.itemIndex, type: 'no_execution_data', diff --git a/packages/workflow/src/workflow.ts b/packages/workflow/src/workflow.ts index 906c0d0997a..a7e625e3cf7 100644 --- a/packages/workflow/src/workflow.ts +++ b/packages/workflow/src/workflow.ts @@ -1004,4 +1004,62 @@ export class Workflow { return result; } + + /** + * Checks if there's a bidirectional path between two nodes. + * This handles AI/tool nodes that have complex connection patterns + * where simple parent-child traversal doesn't work. + * + * @param fromNodeName The starting node name + * @param toNodeName The target node name + * @param maxDepth Maximum depth to search (default: 50) + * @returns true if there's a path between the nodes + */ + hasPath(fromNodeName: string, toNodeName: string, maxDepth = 50): boolean { + if (fromNodeName === toNodeName) return true; + + const visited = new Set(); + const queue: Array<{ nodeName: string; depth: number }> = [ + { nodeName: fromNodeName, depth: 0 }, + ]; + + while (queue.length > 0) { + const { nodeName, depth } = queue.shift()!; + + if (depth > maxDepth) continue; + if (visited.has(nodeName)) continue; + if (nodeName === toNodeName) return true; + + visited.add(nodeName); + + // Check all connection types for this node + const allConnectionTypes = [ + NodeConnectionTypes.Main, + NodeConnectionTypes.AiTool, + NodeConnectionTypes.AiMemory, + NodeConnectionTypes.AiDocument, + NodeConnectionTypes.AiVectorStore, + ]; + + for (const connectionType of allConnectionTypes) { + // Get children (forward direction) + const children = this.getChildNodes(nodeName, connectionType); + for (const childName of children) { + if (!visited.has(childName)) { + queue.push({ nodeName: childName, depth: depth + 1 }); + } + } + + // Get parents (backward direction) + const parents = this.getParentNodes(nodeName, connectionType); + for (const parentName of parents) { + if (!visited.has(parentName)) { + queue.push({ nodeName: parentName, depth: depth + 1 }); + } + } + } + } + + return false; + } } diff --git a/packages/workflow/test/paired-item-path-detection.test.ts b/packages/workflow/test/paired-item-path-detection.test.ts new file mode 100644 index 00000000000..26ff54da43d --- /dev/null +++ b/packages/workflow/test/paired-item-path-detection.test.ts @@ -0,0 +1,757 @@ +import { NodeTypes } from './helpers'; +import { ExpressionError } from '../src/errors/expression.error'; +import type { IExecuteData, INode, IWorkflowBase, IRun, IConnections } from '../src/interfaces'; +import { NodeConnectionTypes } from '../src/interfaces'; +import { Workflow } from '../src/workflow'; +import { WorkflowDataProxy } from '../src/workflow-data-proxy'; + +describe('Paired Item Path Detection', () => { + /** + * Helper to create a minimal workflow for testing + */ + const createWorkflow = (nodes: INode[], connections: IConnections = {}): IWorkflowBase => ({ + id: '1', + name: 'test-workflow', + nodes, + connections, + active: false, + settings: {}, + isArchived: false, + updatedAt: new Date(), + createdAt: new Date(), + }); + + /** + * Helper to create a WorkflowDataProxy for testing + */ + const createProxy = ( + workflow: IWorkflowBase, + activeNodeName: string, + run?: IRun | null, + executeData?: IExecuteData, + ) => { + const wf = new Workflow({ + id: workflow.id, + name: workflow.name, + nodes: workflow.nodes, + connections: workflow.connections, + active: workflow.active, + nodeTypes: NodeTypes(), + settings: workflow.settings, + }); + + return new WorkflowDataProxy( + wf, + run?.data ?? null, + 0, // runIndex + 0, // itemIndex + activeNodeName, + [], // connectionInputData + {}, // siblingParameters + 'manual', // mode + {}, // additionalKeys + executeData, + ).getDataProxy(); + }; + + describe('AI/Tool Node Scenarios', () => { + test('should detect path in bidirectional AI/tool node setup', () => { + // Scenario: Code1 -> Vector Store <- Default Data Loader + const nodes: INode[] = [ + { + id: '1', + name: 'Code1', + type: 'n8n-nodes-base.code', + typeVersion: 1, + position: [100, 100], + parameters: {}, + }, + { + id: '2', + name: 'Vector Store', + type: 'n8n-nodes-langchain.vectorStore', + typeVersion: 1, + position: [300, 100], + parameters: {}, + }, + { + id: '3', + name: 'Default Data Loader', + type: 'n8n-nodes-langchain.documentDefaultDataLoader', + typeVersion: 1, + position: [100, 200], + parameters: {}, + }, + { + id: '4', + name: 'Code2', + type: 'n8n-nodes-base.code', + typeVersion: 1, + position: [500, 100], + parameters: { + jsCode: '// Reference Code1 using $()\nreturn $("Code1").all();', + }, + }, + ]; + + const connections = { + Code1: { + [NodeConnectionTypes.Main]: [ + [{ node: 'Vector Store', type: NodeConnectionTypes.AiVectorStore, index: 0 }], + ], + }, + 'Default Data Loader': { + [NodeConnectionTypes.Main]: [ + [{ node: 'Vector Store', type: NodeConnectionTypes.AiDocument, index: 0 }], + ], + }, + 'Vector Store': { + [NodeConnectionTypes.Main]: [ + [{ node: 'Code2', type: NodeConnectionTypes.Main, index: 0 }], + ], + }, + }; + + const workflow = createWorkflow(nodes, connections); + const wf = new Workflow({ + id: workflow.id, + name: workflow.name, + nodes: workflow.nodes, + connections: workflow.connections, + active: workflow.active, + nodeTypes: NodeTypes(), + settings: workflow.settings, + }); + + // Test bidirectional path detection + expect(wf.hasPath('Code1', 'Code2')).toBe(true); + expect(wf.hasPath('Default Data Loader', 'Code2')).toBe(true); + expect(wf.hasPath('Code1', 'Default Data Loader')).toBe(true); // Via Vector Store + + // Test that unconnected nodes return false + const unconnectedNode: INode = { + id: '5', + name: 'Unconnected', + type: 'n8n-nodes-base.code', + typeVersion: 1, + position: [700, 100], + parameters: {}, + }; + const workflowWithUnconnected = createWorkflow([...nodes, unconnectedNode], connections); + const wfWithUnconnected = new Workflow({ + id: workflowWithUnconnected.id, + name: workflowWithUnconnected.name, + nodes: workflowWithUnconnected.nodes, + connections: workflowWithUnconnected.connections, + active: workflowWithUnconnected.active, + nodeTypes: NodeTypes(), + settings: workflowWithUnconnected.settings, + }); + + expect(wfWithUnconnected.hasPath('Code1', 'Unconnected')).toBe(false); + }); + + test('should handle complex AI tool connection patterns', () => { + // More complex AI scenario with multiple connection types + const nodes: INode[] = [ + { + id: '1', + name: 'Agent', + type: 'n8n-nodes-langchain.agent', + typeVersion: 1, + position: [300, 300], + parameters: {}, + }, + { + id: '2', + name: 'Tool1', + type: 'n8n-nodes-langchain.toolHttpRequest', + typeVersion: 1, + position: [100, 200], + parameters: {}, + }, + { + id: '3', + name: 'Tool2', + type: 'n8n-nodes-langchain.toolCalculator', + typeVersion: 1, + position: [100, 400], + parameters: {}, + }, + { + id: '4', + name: 'Memory', + type: 'n8n-nodes-langchain.memoryBufferMemory', + typeVersion: 1, + position: [200, 100], + parameters: {}, + }, + ]; + + const connections = { + Tool1: { + [NodeConnectionTypes.AiTool]: [ + [{ node: 'Agent', type: NodeConnectionTypes.AiTool, index: 0 }], + ], + }, + Tool2: { + [NodeConnectionTypes.AiTool]: [ + [{ node: 'Agent', type: NodeConnectionTypes.AiTool, index: 1 }], + ], + }, + Memory: { + [NodeConnectionTypes.AiMemory]: [ + [{ node: 'Agent', type: NodeConnectionTypes.AiMemory, index: 0 }], + ], + }, + }; + + const workflow = createWorkflow(nodes, connections); + const wf = new Workflow({ + id: workflow.id, + name: workflow.name, + nodes: workflow.nodes, + connections: workflow.connections, + active: workflow.active, + nodeTypes: NodeTypes(), + settings: workflow.settings, + }); + + // Test all tools can reach the agent + expect(wf.hasPath('Tool1', 'Agent')).toBe(true); + expect(wf.hasPath('Tool2', 'Agent')).toBe(true); + expect(wf.hasPath('Memory', 'Agent')).toBe(true); + + // Test bidirectional paths + expect(wf.hasPath('Agent', 'Tool1')).toBe(true); + expect(wf.hasPath('Agent', 'Tool2')).toBe(true); + expect(wf.hasPath('Agent', 'Memory')).toBe(true); + + // Test indirect connections + expect(wf.hasPath('Tool1', 'Tool2')).toBe(true); // Via Agent + expect(wf.hasPath('Memory', 'Tool1')).toBe(true); // Via Agent + }); + }); + + describe('Manual Execution Node-Not-Found Scenarios', () => { + test('should throw "No path back to referenced node" when node does not exist in trimmed workflow', () => { + // Simulate manual execution scenario where node D is not in the trimmed workflow + const nodes: INode[] = [ + { + id: '1', + name: 'A', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [100, 100], + parameters: {}, + }, + { + id: '2', + name: 'B', + type: 'n8n-nodes-base.code', + typeVersion: 1, + position: [300, 100], + parameters: { + jsCode: 'return $("D").all(); // Reference missing node D', + }, + }, + { + id: '3', + name: 'C', + type: 'n8n-nodes-base.code', + typeVersion: 1, + position: [500, 100], + parameters: {}, + }, + ]; + + const connections = { + A: { + [NodeConnectionTypes.Main]: [[{ node: 'B', type: NodeConnectionTypes.Main, index: 0 }]], + }, + B: { + [NodeConnectionTypes.Main]: [[{ node: 'C', type: NodeConnectionTypes.Main, index: 0 }]], + }, + }; + + const workflow = createWorkflow(nodes, connections); + const proxy = createProxy(workflow, 'B'); + + // Should throw error when trying to access non-existent node D + expect(() => proxy.$('D')).toThrowError(ExpressionError); + expect(() => proxy.$('D')).toThrow(/Error finding the referenced node/); + }); + + test('should throw "No path back to referenced node" when node exists but has no path', () => { + // Node D exists but is not connected + const nodes: INode[] = [ + { + id: '1', + name: 'A', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [100, 100], + parameters: {}, + }, + { + id: '2', + name: 'B', + type: 'n8n-nodes-base.code', + typeVersion: 1, + position: [300, 100], + parameters: { + jsCode: 'return $("D").all(); // Reference unconnected node D', + }, + }, + { + id: '3', + name: 'C', + type: 'n8n-nodes-base.code', + typeVersion: 1, + position: [500, 100], + parameters: {}, + }, + { + id: '4', + name: 'D', + type: 'n8n-nodes-base.code', + typeVersion: 1, + position: [100, 300], + parameters: {}, + }, + ]; + + const connections = { + A: { + [NodeConnectionTypes.Main]: [[{ node: 'B', type: NodeConnectionTypes.Main, index: 0 }]], + }, + B: { + [NodeConnectionTypes.Main]: [[{ node: 'C', type: NodeConnectionTypes.Main, index: 0 }]], + }, + // D is not connected + }; + + const workflow = createWorkflow(nodes, connections); + + // Create executeData to simulate a real execution context + const executeData: IExecuteData = { + data: { + main: [[]], + }, + node: nodes.find((n) => n.name === 'B')!, + source: { + main: [ + { + previousNode: 'A', + previousNodeOutput: 0, + previousNodeRun: 0, + }, + ], + }, + }; + + const proxy = createProxy(workflow, 'B', null, executeData); + + // Should throw error when trying to access paired item from unconnected node D + let error: ExpressionError | undefined; + try { + proxy.$('D').item; + } catch (e) { + error = e as ExpressionError; + } + + expect(error).toBeDefined(); + expect(error).toBeInstanceOf(ExpressionError); + expect(error!.context.type).toBe('paired_item_no_connection'); + expect(error!.context.descriptionKey).toBe('pairedItemNoConnectionCodeNode'); + }); + }); + + describe('Workflow.hasPath method', () => { + test('should handle self-reference', () => { + const nodes: INode[] = [ + { + id: '1', + name: 'A', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [100, 100], + parameters: {}, + }, + ]; + + const workflow = createWorkflow(nodes, {}); + const wf = new Workflow({ + id: workflow.id, + name: workflow.name, + nodes: workflow.nodes, + connections: workflow.connections, + active: workflow.active, + nodeTypes: NodeTypes(), + settings: workflow.settings, + }); + + expect(wf.hasPath('A', 'A')).toBe(true); + }); + + test('should respect maximum depth limit', () => { + const nodes: INode[] = [ + { + id: '1', + name: 'A', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [100, 100], + parameters: {}, + }, + { + id: '2', + name: 'B', + type: 'n8n-nodes-base.code', + typeVersion: 1, + position: [300, 100], + parameters: {}, + }, + ]; + + const connections = { + A: { + [NodeConnectionTypes.Main]: [[{ node: 'B', type: NodeConnectionTypes.Main, index: 0 }]], + }, + }; + + const workflow = createWorkflow(nodes, connections); + const wf = new Workflow({ + id: workflow.id, + name: workflow.name, + nodes: workflow.nodes, + connections: workflow.connections, + active: workflow.active, + nodeTypes: NodeTypes(), + settings: workflow.settings, + }); + + // Should find path with sufficient depth + expect(wf.hasPath('A', 'B', 10)).toBe(true); + + // Should not find path with insufficient depth + expect(wf.hasPath('A', 'B', 0)).toBe(false); + }); + + test('should handle cycles without infinite loops', () => { + const nodes: INode[] = [ + { + id: '1', + name: 'A', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [100, 100], + parameters: {}, + }, + { + id: '2', + name: 'B', + type: 'n8n-nodes-base.code', + typeVersion: 1, + position: [300, 100], + parameters: {}, + }, + { + id: '3', + name: 'C', + type: 'n8n-nodes-base.code', + typeVersion: 1, + position: [500, 100], + parameters: {}, + }, + ]; + + // Create a cycle: A -> B -> C -> A + const connections = { + A: { + [NodeConnectionTypes.Main]: [[{ node: 'B', type: NodeConnectionTypes.Main, index: 0 }]], + }, + B: { + [NodeConnectionTypes.Main]: [[{ node: 'C', type: NodeConnectionTypes.Main, index: 0 }]], + }, + C: { + [NodeConnectionTypes.Main]: [[{ node: 'A', type: NodeConnectionTypes.Main, index: 0 }]], + }, + }; + + const workflow = createWorkflow(nodes, connections); + const wf = new Workflow({ + id: workflow.id, + name: workflow.name, + nodes: workflow.nodes, + connections: workflow.connections, + active: workflow.active, + nodeTypes: NodeTypes(), + settings: workflow.settings, + }); + + // Should handle cycles correctly + expect(wf.hasPath('A', 'C')).toBe(true); + expect(wf.hasPath('B', 'A')).toBe(true); + expect(wf.hasPath('C', 'B')).toBe(true); + }); + }); + + describe('Actual workflow', () => { + test('should show correct error message for disconnected nodes', () => { + // Recreate the exact scenario from the user's workflow + const nodes: INode[] = [ + { + id: 'afc0fc26-d521-4464-9f90-3327559bd4a6', + name: 'On form submission', + type: 'n8n-nodes-base.formTrigger', + typeVersion: 2.2, + position: [0, 0], + parameters: { + formTitle: 'Submit BBS application', + }, + }, + { + id: 'c5861385-d513-4d74-8fe3-e5acbe08a90a', + name: 'Code', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [288, 432], + parameters: { + jsCode: "\nreturn $('On form submission').all();", + }, + }, + { + id: '523b019b-e456-4784-a50a-18558c858c3b', + name: "When clicking 'Test workflow'", + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 288], + parameters: {}, + }, + { + id: '3057aebb-d87a-4142-8354-f298e41ab919', + name: 'Edit Fields', + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [288, 128], + parameters: { + assignments: { + assignments: [ + { + id: '9c260756-a7ce-41ba-ad9b-0eb1ceeaf02b', + name: 'test', + value: "={{ $('On form submission').item.json }}", + type: 'string', + }, + ], + }, + }, + }, + ]; + + const connections = { + 'On form submission': { + [NodeConnectionTypes.Main]: [[]], + }, + "When clicking 'Test workflow'": { + [NodeConnectionTypes.Main]: [ + [ + { node: 'Code', type: NodeConnectionTypes.Main, index: 0 }, + { node: 'Edit Fields', type: NodeConnectionTypes.Main, index: 0 }, + ], + ], + }, + }; + + const workflow = createWorkflow(nodes, connections); + const proxy = createProxy(workflow, 'Code'); + + // Should throw the correct error when trying to access disconnected node + let error: ExpressionError | undefined; + try { + proxy.$('On form submission').all(); + } catch (e) { + error = e as ExpressionError; + } + + expect(error).toBeDefined(); + expect(error).toBeInstanceOf(ExpressionError); + expect(error!.context.type).toBe('paired_item_no_connection'); + expect(error!.context.descriptionKey).toBe('pairedItemNoConnection'); + expect(error!.message).toBe('Error finding the referenced node'); + expect(error!.context.messageTemplate).toBe( + 'Make sure the node you referenced is spelled correctly and is a parent of this node', + ); + }); + + test('should also show correct error for Edit Fields node', () => { + // Test the Edit Fields node as well + const nodes: INode[] = [ + { + id: 'afc0fc26-d521-4464-9f90-3327559bd4a6', + name: 'On form submission', + type: 'n8n-nodes-base.formTrigger', + typeVersion: 2.2, + position: [0, 0], + parameters: { + formTitle: 'Submit BBS application', + }, + }, + { + id: 'c5861385-d513-4d74-8fe3-e5acbe08a90a', + name: 'Code', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [288, 432], + parameters: { + jsCode: "\nreturn $('On form submission').all();", + }, + }, + { + id: '523b019b-e456-4784-a50a-18558c858c3b', + name: "When clicking 'Test workflow'", + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 288], + parameters: {}, + }, + { + id: '3057aebb-d87a-4142-8354-f298e41ab919', + name: 'Edit Fields', + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [288, 128], + parameters: { + assignments: { + assignments: [ + { + id: '9c260756-a7ce-41ba-ad9b-0eb1ceeaf02b', + name: 'test', + value: "={{ $('On form submission').item.json }}", + type: 'string', + }, + ], + }, + }, + }, + ]; + + const connections = { + 'On form submission': { + [NodeConnectionTypes.Main]: [[]], + }, + "When clicking 'Test workflow'": { + [NodeConnectionTypes.Main]: [ + [ + { node: 'Code', type: NodeConnectionTypes.Main, index: 0 }, + { node: 'Edit Fields', type: NodeConnectionTypes.Main, index: 0 }, + ], + ], + }, + }; + + const workflow = createWorkflow(nodes, connections); + const proxy = createProxy(workflow, 'Edit Fields'); + + // Should throw the correct error when trying to access disconnected node + let error: ExpressionError | undefined; + try { + proxy.$('On form submission').item; + } catch (e) { + error = e as ExpressionError; + } + + expect(error).toBeDefined(); + expect(error).toBeInstanceOf(ExpressionError); + expect(error!.context.type).toBe('paired_item_no_connection'); + expect(error!.context.descriptionKey).toBe('pairedItemNoConnection'); + expect(error!.message).toBe('Error finding the referenced node'); + expect(error!.context.messageTemplate).toBe( + 'Make sure the node you referenced is spelled correctly and is a parent of this node', + ); + }); + + test('should show correct error in runtime execution context', () => { + // Test with execution data to simulate real runtime + const nodes: INode[] = [ + { + id: 'afc0fc26-d521-4464-9f90-3327559bd4a6', + name: 'On form submission', + type: 'n8n-nodes-base.formTrigger', + typeVersion: 2.2, + position: [0, 0], + parameters: { + formTitle: 'Submit BBS application', + }, + }, + { + id: 'c5861385-d513-4d74-8fe3-e5acbe08a90a', + name: 'Code', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [288, 432], + parameters: { + jsCode: "\nreturn $('On form submission').all();", + }, + }, + { + id: '523b019b-e456-4784-a50a-18558c858c3b', + name: "When clicking 'Test workflow'", + type: 'n8n-nodes-base.manualTrigger', + typeVersion: 1, + position: [0, 288], + parameters: {}, + }, + ]; + + const connections = { + 'On form submission': { + [NodeConnectionTypes.Main]: [[]], + }, + "When clicking 'Test workflow'": { + [NodeConnectionTypes.Main]: [ + [{ node: 'Code', type: NodeConnectionTypes.Main, index: 0 }], + ], + }, + }; + + const workflow = createWorkflow(nodes, connections); + + // Create execution data to simulate real workflow execution + const executeData: IExecuteData = { + data: { + main: [[]], + }, + node: nodes.find((n) => n.name === 'Code')!, + source: { + main: [ + { + previousNode: "When clicking 'Test workflow'", + previousNodeOutput: 0, + previousNodeRun: 0, + }, + ], + }, + }; + + const proxy = createProxy(workflow, 'Code', null, executeData); + + // Should throw the correct error when trying to access disconnected node during execution + let error: ExpressionError | undefined; + try { + proxy.$('On form submission').all(); + } catch (e) { + error = e as ExpressionError; + } + + expect(error).toBeDefined(); + expect(error).toBeInstanceOf(ExpressionError); + expect(error!.context.type).toBe('paired_item_no_connection'); + expect(error!.message).toBe('Error finding the referenced node'); + expect(error!.context.messageTemplate).toBe( + 'Make sure the node you referenced is spelled correctly and is a parent of this node', + ); + }); + }); +}); diff --git a/packages/workflow/test/workflow-data-proxy.test.ts b/packages/workflow/test/workflow-data-proxy.test.ts index c4365653097..7cbcf3a8e02 100644 --- a/packages/workflow/test/workflow-data-proxy.test.ts +++ b/packages/workflow/test/workflow-data-proxy.test.ts @@ -235,7 +235,7 @@ describe('WorkflowDataProxy', () => { } catch (error) { expect(error).toBeInstanceOf(ExpressionError); const exprError = error as ExpressionError; - expect(exprError.message).toEqual("Referenced node doesn't exist"); + expect(exprError.message).toEqual('Error finding the referenced node'); } }); @@ -246,7 +246,7 @@ describe('WorkflowDataProxy', () => { } catch (error) { expect(error).toBeInstanceOf(ExpressionError); const exprError = error as ExpressionError; - expect(exprError.message).toEqual('Invalid expression'); + expect(exprError.message).toEqual('Error finding the referenced node'); expect(exprError.context.type).toEqual('paired_item_no_connection'); } }); @@ -262,8 +262,8 @@ describe('WorkflowDataProxy', () => { } catch (error) { expect(error).toBeInstanceOf(ExpressionError); const exprError = error as ExpressionError; - expect(exprError.message).toEqual('Referenced node is unexecuted'); - expect(exprError.context.type).toEqual('no_node_execution_data'); + expect(exprError.message).toEqual('Error finding the referenced node'); + expect(exprError.context.type).toEqual('paired_item_no_connection'); } }); @@ -286,8 +286,8 @@ describe('WorkflowDataProxy', () => { } catch (error) { expect(error).toBeInstanceOf(ExpressionError); const exprError = error as ExpressionError; - expect(exprError.message).toEqual('Referenced node is unexecuted'); - expect(exprError.context.type).toEqual('no_node_execution_data'); + expect(exprError.message).toEqual('Error finding the referenced node'); + expect(exprError.context.type).toEqual('paired_item_no_connection'); } }); diff --git a/packages/workflow/test/workflow.test.ts b/packages/workflow/test/workflow.test.ts index 0c37c3a02fc..50de376e371 100644 --- a/packages/workflow/test/workflow.test.ts +++ b/packages/workflow/test/workflow.test.ts @@ -2890,4 +2890,346 @@ describe('Workflow', () => { expect(result).toEqual([]); }); }); + + describe('hasPath method', () => { + test('should return true for self-reference', () => { + const workflow = new Workflow({ + id: 'test', + nodes: [ + { + id: 'Node1', + name: 'Node1', + type: 'test.set', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + ], + connections: {}, + active: false, + nodeTypes, + }); + + expect(workflow.hasPath('Node1', 'Node1')).toBe(true); + }); + + test('should return false when nodes are not connected', () => { + const workflow = new Workflow({ + id: 'test', + nodes: [ + { + id: 'Node1', + name: 'Node1', + type: 'test.set', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: 'Node2', + name: 'Node2', + type: 'test.set', + typeVersion: 1, + position: [100, 0], + parameters: {}, + }, + ], + connections: {}, + active: false, + nodeTypes, + }); + + expect(workflow.hasPath('Node1', 'Node2')).toBe(false); + }); + + test('should return true for directly connected nodes', () => { + const workflow = new Workflow({ + id: 'test', + nodes: [ + { + id: 'Node1', + name: 'Node1', + type: 'test.set', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: 'Node2', + name: 'Node2', + type: 'test.set', + typeVersion: 1, + position: [100, 0], + parameters: {}, + }, + ], + connections: { + Node1: { + [NodeConnectionTypes.Main]: [ + [{ node: 'Node2', type: NodeConnectionTypes.Main, index: 0 }], + ], + }, + }, + active: false, + nodeTypes, + }); + + expect(workflow.hasPath('Node1', 'Node2')).toBe(true); + expect(workflow.hasPath('Node2', 'Node1')).toBe(true); + }); + + test('should respect maximum depth limit', () => { + const workflow = new Workflow({ + id: 'test', + nodes: [ + { + id: 'Node1', + name: 'Node1', + type: 'test.set', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: 'Node2', + name: 'Node2', + type: 'test.set', + typeVersion: 1, + position: [100, 0], + parameters: {}, + }, + ], + connections: { + Node1: { + [NodeConnectionTypes.Main]: [ + [{ node: 'Node2', type: NodeConnectionTypes.Main, index: 0 }], + ], + }, + }, + active: false, + nodeTypes, + }); + + // Should find path with sufficient depth + expect(workflow.hasPath('Node1', 'Node2', 5)).toBe(true); + expect(workflow.hasPath('Node1', 'Node2', 1)).toBe(true); + + // Should not find path with insufficient depth + expect(workflow.hasPath('Node1', 'Node2', 0)).toBe(false); + }); + + test('should handle AI connection types', () => { + const workflow = new Workflow({ + id: 'test', + nodes: [ + { + id: 'Agent', + name: 'Agent', + type: 'test.ai.agent', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: 'Tool1', + name: 'Tool1', + type: 'test.ai.tool', + typeVersion: 1, + position: [100, 0], + parameters: {}, + }, + { + id: 'Memory', + name: 'Memory', + type: 'test.ai.memory', + typeVersion: 1, + position: [200, 0], + parameters: {}, + }, + ], + connections: { + Tool1: { + [NodeConnectionTypes.AiTool]: [ + [{ node: 'Agent', type: NodeConnectionTypes.AiTool, index: 0 }], + ], + }, + Memory: { + [NodeConnectionTypes.AiMemory]: [ + [{ node: 'Agent', type: NodeConnectionTypes.AiMemory, index: 0 }], + ], + }, + }, + active: false, + nodeTypes, + }); + + expect(workflow.hasPath('Tool1', 'Agent')).toBe(true); + expect(workflow.hasPath('Memory', 'Agent')).toBe(true); + expect(workflow.hasPath('Tool1', 'Memory')).toBe(true); + }); + + test('should handle complex paths with multiple connection types', () => { + const workflow = new Workflow({ + id: 'test', + nodes: [ + { + id: 'Start', + name: 'Start', + type: 'test.start', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: 'VectorStore', + name: 'VectorStore', + type: 'test.vectorstore', + typeVersion: 1, + position: [100, 0], + parameters: {}, + }, + { + id: 'Document', + name: 'Document', + type: 'test.document', + typeVersion: 1, + position: [200, 0], + parameters: {}, + }, + { + id: 'End', + name: 'End', + type: 'test.end', + typeVersion: 1, + position: [300, 0], + parameters: {}, + }, + ], + connections: { + Start: { + [NodeConnectionTypes.Main]: [ + [{ node: 'VectorStore', type: NodeConnectionTypes.AiVectorStore, index: 0 }], + ], + }, + Document: { + [NodeConnectionTypes.Main]: [ + [{ node: 'VectorStore', type: NodeConnectionTypes.AiDocument, index: 0 }], + ], + }, + VectorStore: { + [NodeConnectionTypes.Main]: [ + [{ node: 'End', type: NodeConnectionTypes.Main, index: 0 }], + ], + }, + }, + active: false, + nodeTypes, + }); + + expect(workflow.hasPath('Start', 'End')).toBe(true); + expect(workflow.hasPath('Document', 'End')).toBe(true); + expect(workflow.hasPath('Start', 'Document')).toBe(true); + }); + + test('should handle cyclic graphs without infinite loops', () => { + const workflow = new Workflow({ + id: 'test', + nodes: [ + { + id: 'Node1', + name: 'Node1', + type: 'test.set', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: 'Node2', + name: 'Node2', + type: 'test.set', + typeVersion: 1, + position: [100, 0], + parameters: {}, + }, + { + id: 'Node3', + name: 'Node3', + type: 'test.set', + typeVersion: 1, + position: [200, 0], + parameters: {}, + }, + ], + connections: { + Node1: { + [NodeConnectionTypes.Main]: [ + [{ node: 'Node2', type: NodeConnectionTypes.Main, index: 0 }], + ], + }, + Node2: { + [NodeConnectionTypes.Main]: [ + [{ node: 'Node3', type: NodeConnectionTypes.Main, index: 0 }], + ], + }, + Node3: { + [NodeConnectionTypes.Main]: [ + [{ node: 'Node1', type: NodeConnectionTypes.Main, index: 0 }], + ], + }, + }, + active: false, + nodeTypes, + }); + + expect(workflow.hasPath('Node1', 'Node3')).toBe(true); + expect(workflow.hasPath('Node2', 'Node1')).toBe(true); + expect(workflow.hasPath('Node3', 'Node2')).toBe(true); + }); + + test('should handle empty workflow', () => { + const workflow = new Workflow({ + id: 'test', + nodes: [], + connections: {}, + active: false, + nodeTypes, + }); + + expect(workflow.hasPath('NonExistent1', 'NonExistent2')).toBe(false); + }); + + test('should handle nodes with no outgoing connections', () => { + const workflow = new Workflow({ + id: 'test', + nodes: [ + { + id: 'Node1', + name: 'Node1', + type: 'test.set', + typeVersion: 1, + position: [0, 0], + parameters: {}, + }, + { + id: 'Node2', + name: 'Node2', + type: 'test.set', + typeVersion: 1, + position: [100, 0], + parameters: {}, + }, + ], + connections: { + Node1: { + [NodeConnectionTypes.Main]: [[]], + }, + }, + active: false, + nodeTypes, + }); + + expect(workflow.hasPath('Node1', 'Node2')).toBe(false); + expect(workflow.hasPath('Node2', 'Node1')).toBe(false); + }); + }); }); From 9df03f6d5a4f5d9c6aad9c09220216872b297aa5 Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Tue, 22 Jul 2025 10:31:10 +0200 Subject: [PATCH 007/142] feat(editor): Add separate Focus Panel button to ParameterOptions (no-changelog) (#17361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Milorad FIlipović --- .../src/components/ParameterOptions.vue | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/packages/frontend/editor-ui/src/components/ParameterOptions.vue b/packages/frontend/editor-ui/src/components/ParameterOptions.vue index 69ba7cca500..d2999b2bd44 100644 --- a/packages/frontend/editor-ui/src/components/ParameterOptions.vue +++ b/packages/frontend/editor-ui/src/components/ParameterOptions.vue @@ -55,7 +55,7 @@ const shouldShowExpressionSelector = computed( const isFocusPanelFeatureEnabled = computed(() => { return posthogStore.getVariant(FOCUS_PANEL_EXPERIMENT.name) === FOCUS_PANEL_EXPERIMENT.variant; }); -const hasFocusAction = computed( +const canBeOpenedInFocusPanel = computed( () => isFocusPanelFeatureEnabled.value && !props.parameter.isNodeSetting && @@ -73,10 +73,6 @@ const shouldShowOptions = computed(() => { return false; } - if (hasFocusAction.value) { - return true; - } - if (['codeNodeEditor', 'sqlEditor'].includes(props.parameter.typeOptions?.editor ?? '')) { return false; } @@ -105,19 +101,13 @@ const actions = computed(() => { return props.customActions; } - const focusAction = { - label: i18n.baseText('parameterInput.focusParameter'), - value: 'focus', - disabled: false, - }; - if (isHtmlEditor.value && !isValueAnExpression.value) { - const formatHtmlAction = { - label: i18n.baseText('parameterInput.formatHtml'), - value: 'formatHtml', - }; - - return hasFocusAction.value ? [formatHtmlAction, focusAction] : [formatHtmlAction]; + return [ + { + label: i18n.baseText('parameterInput.formatHtml'), + value: 'formatHtml', + }, + ]; } const resetAction = { @@ -131,11 +121,7 @@ const actions = computed(() => { props.parameter.typeOptions?.editor ?? '', ); - // Conditionally build actions array without nulls to ensure correct typing - const parameterActions = [ - hasResetAction ? [resetAction] : [], - hasFocusAction.value ? [focusAction] : [], - ].flat(); + const parameterActions = [hasResetAction ? resetAction : []].flat(); if ( hasRemoteMethod.value || @@ -176,6 +162,15 @@ const onViewSelected = (selected: string) => {
+ + + +
+$container-height: 22px; + .container { display: flex; - min-height: 22px; + min-height: $container-height; + max-height: $container-height; } .loader { @@ -234,4 +232,11 @@ const onViewSelected = (selected: string) => { padding-right: 0 !important; } } + +.focusButton { + &:hover { + cursor: pointer; + color: var(--color-primary); + } +} From 8fb3d8d5870682af4b8b0c31949b5c1569a70d90 Mon Sep 17 00:00:00 2001 From: oleg Date: Tue, 22 Jul 2025 10:41:14 +0200 Subject: [PATCH 008/142] fix(GitHub Document Loader Node): Fix node loading issue (#17494) --- .../DocumentGithubLoader/DocumentGithubLoader.node.ts | 5 ++--- packages/@n8n/nodes-langchain/package.json | 1 + pnpm-lock.yaml | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) 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 f12a3392432..a25125df03a 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 @@ -1,6 +1,8 @@ import { GithubRepoLoader } from '@langchain/community/document_loaders/web/github'; import type { TextSplitter } from '@langchain/textsplitters'; import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'; +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; import { NodeConnectionTypes, type INodeType, @@ -11,9 +13,6 @@ import { type INodeInputConfiguration, } from 'n8n-workflow'; -import { logWrapper } from '@utils/logWrapper'; -import { getConnectionHintNoticeField } from '@utils/sharedFields'; - function getInputs(parameters: IDataObject) { const inputs: INodeInputConfiguration[] = []; diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index ee06e625c68..2d62e6553bf 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -207,6 +207,7 @@ "generate-schema": "2.6.0", "html-to-text": "9.0.5", "https-proxy-agent": "catalog:", + "ignore": "^5.2.0", "js-tiktoken": "^1.0.12", "jsdom": "23.0.1", "langchain": "0.3.29", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8ce9ad3569..e51b3429280 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1031,6 +1031,9 @@ importers: https-proxy-agent: specifier: 'catalog:' version: 7.0.6 + ignore: + specifier: ^5.2.0 + version: 5.2.4 js-tiktoken: specifier: ^1.0.12 version: 1.0.12 From c1aae67a04c7ea3994b8f64d7866c56f7b49ce69 Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Tue, 22 Jul 2025 10:44:57 +0200 Subject: [PATCH 009/142] fix(editor): Don't hide FocusPanel when NodeCreator is opened to avoid canvas shift (no-changelog) (#17454) --- .../editor-ui/src/components/FocusPanel.vue | 3 +-- .../Node/NodeCreator/NodeCreator.vue | 3 +++ .../editor-ui/src/composables/useStyles.ts | 2 +- .../editor-ui/src/stores/focusPanel.store.ts | 19 ++----------------- .../frontend/editor-ui/src/views/NodeView.vue | 12 ------------ 5 files changed, 7 insertions(+), 32 deletions(-) diff --git a/packages/frontend/editor-ui/src/components/FocusPanel.vue b/packages/frontend/editor-ui/src/components/FocusPanel.vue index a1b99383b67..a198c72833d 100644 --- a/packages/frontend/editor-ui/src/components/FocusPanel.vue +++ b/packages/frontend/editor-ui/src/components/FocusPanel.vue @@ -67,7 +67,6 @@ const resolvedParameter = computed(() => ); const focusPanelActive = computed(() => focusPanelStore.focusPanelActive); -const focusPanelHidden = computed(() => focusPanelStore.focusPanelHidden); const focusPanelWidth = computed(() => focusPanelStore.focusPanelWidth); const isDisabled = computed(() => { @@ -314,7 +313,7 @@ const onResizeThrottle = useThrottleFn(onResize, 10);