fix(core): Fix supportedNodes for non-lazy loaded community packages (no-changelog) (#11329)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2024-12-10 14:48:39 +01:00
committed by GitHub
parent 1c52bf9362
commit 2d36b42798
28 changed files with 1435 additions and 586 deletions

View File

@@ -7,13 +7,12 @@
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm run watch", "dev": "pnpm run watch",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json && pnpm n8n-copy-icons && pnpm build:metadata", "build": "tsc -p tsconfig.build.json && pnpm n8n-copy-icons && pnpm n8n-generate-metadata",
"build:metadata": "pnpm n8n-generate-known && pnpm n8n-generate-ui-types",
"format": "biome format --write .", "format": "biome format --write .",
"format:check": "biome ci .", "format:check": "biome ci .",
"lint": "eslint nodes credentials --quiet", "lint": "eslint nodes credentials --quiet",
"lintfix": "eslint nodes credentials --fix", "lintfix": "eslint nodes credentials --fix",
"watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\" --onSuccess \"pnpm n8n-generate-ui-types\"", "watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\" --onSuccess \"pnpm n8n-generate-metadata\"",
"test": "jest", "test": "jest",
"test:dev": "jest --watch" "test:dev": "jest --watch"
}, },

View File

@@ -1,41 +1,121 @@
import { Container } from 'typedi'; import { mock } from 'jest-mock-extended';
import { UnrecognizedCredentialTypeError } from 'n8n-core';
import type { ICredentialType, LoadedClass } from 'n8n-workflow';
import { CredentialTypes } from '@/credential-types'; import { CredentialTypes } from '@/credential-types';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { mockInstance } from '@test/mocking';
describe('CredentialTypes', () => { describe('CredentialTypes', () => {
const mockNodesAndCredentials = mockInstance(LoadNodesAndCredentials, { const loadNodesAndCredentials = mock<LoadNodesAndCredentials>();
loadedCredentials: {
fakeFirstCredential: { const credentialTypes = new CredentialTypes(loadNodesAndCredentials);
type: {
name: 'fakeFirstCredential', const testCredential: LoadedClass<ICredentialType> = {
displayName: 'Fake First Credential', sourcePath: '',
properties: [], type: mock(),
}, };
sourcePath: '',
}, loadNodesAndCredentials.getCredential.mockImplementation((credentialType) => {
fakeSecondCredential: { if (credentialType === 'testCredential') return testCredential;
type: { throw new UnrecognizedCredentialTypeError(credentialType);
name: 'fakeSecondCredential',
displayName: 'Fake Second Credential',
properties: [],
},
sourcePath: '',
},
},
}); });
const credentialTypes = Container.get(CredentialTypes); beforeEach(() => {
jest.clearAllMocks();
test('Should throw error when calling invalid credential name', () => {
expect(() => credentialTypes.getByName('fakeThirdCredential')).toThrowError();
}); });
test('Should return correct credential type for valid name', () => { describe('getByName', () => {
const mockedCredentialTypes = mockNodesAndCredentials.loadedCredentials; test('Should throw error when calling invalid credential name', () => {
expect(credentialTypes.getByName('fakeFirstCredential')).toStrictEqual( expect(() => credentialTypes.getByName('unknownCredential')).toThrowError('c');
mockedCredentialTypes.fakeFirstCredential.type, });
);
test('Should return correct credential type for valid name', () => {
expect(credentialTypes.getByName('testCredential')).toStrictEqual(testCredential.type);
});
});
describe('recognizes', () => {
test('Should recognize credential type that exists in knownCredentials', () => {
const credentialTypes = new CredentialTypes(
mock<LoadNodesAndCredentials>({
loadedCredentials: {},
knownCredentials: { testCredential: mock({ supportedNodes: [] }) },
}),
);
expect(credentialTypes.recognizes('testCredential')).toBe(true);
});
test('Should recognize credential type that exists in loadedCredentials', () => {
const credentialTypes = new CredentialTypes(
mock<LoadNodesAndCredentials>({
loadedCredentials: { testCredential },
knownCredentials: {},
}),
);
expect(credentialTypes.recognizes('testCredential')).toBe(true);
});
test('Should not recognize unknown credential type', () => {
expect(credentialTypes.recognizes('unknownCredential')).toBe(false);
});
});
describe('getSupportedNodes', () => {
test('Should return supported nodes for known credential type', () => {
const supportedNodes = ['node1', 'node2'];
const credentialTypes = new CredentialTypes(
mock<LoadNodesAndCredentials>({
knownCredentials: { testCredential: mock({ supportedNodes }) },
}),
);
expect(credentialTypes.getSupportedNodes('testCredential')).toEqual(supportedNodes);
});
test('Should return empty array for unknown credential type supported nodes', () => {
expect(credentialTypes.getSupportedNodes('unknownCredential')).toBeEmptyArray();
});
});
describe('getParentTypes', () => {
test('Should return parent types for credential type with extends', () => {
const credentialTypes = new CredentialTypes(
mock<LoadNodesAndCredentials>({
knownCredentials: {
childType: { extends: ['parentType1', 'parentType2'] },
parentType1: { extends: ['grandparentType'] },
parentType2: { extends: [] },
grandparentType: { extends: [] },
},
}),
);
const parentTypes = credentialTypes.getParentTypes('childType');
expect(parentTypes).toContain('parentType1');
expect(parentTypes).toContain('parentType2');
expect(parentTypes).toContain('grandparentType');
});
test('Should return empty array for credential type without extends', () => {
const credentialTypes = new CredentialTypes(
mock<LoadNodesAndCredentials>({
knownCredentials: { testCredential: { extends: [] } },
}),
);
expect(credentialTypes.getParentTypes('testCredential')).toBeEmptyArray();
});
test('Should return empty array for unknown credential type parent types', () => {
const credentialTypes = new CredentialTypes(
mock<LoadNodesAndCredentials>({
knownCredentials: {},
}),
);
expect(credentialTypes.getParentTypes('unknownCredential')).toBeEmptyArray();
});
}); });
}); });

View File

@@ -1,3 +1,4 @@
import { mock } from 'jest-mock-extended';
import type { import type {
IAuthenticateGeneric, IAuthenticateGeneric,
ICredentialDataDecryptedObject, ICredentialDataDecryptedObject,
@@ -5,59 +6,25 @@ import type {
IHttpRequestOptions, IHttpRequestOptions,
INode, INode,
INodeProperties, INodeProperties,
INodeTypes,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeConnectionType, deepCopy } from 'n8n-workflow'; import { deepCopy, Workflow } from 'n8n-workflow';
import { Workflow } from 'n8n-workflow';
import Container from 'typedi';
import { CredentialTypes } from '@/credential-types';
import { CredentialsHelper } from '@/credentials-helper'; import { CredentialsHelper } from '@/credentials-helper';
import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { NodeTypes } from '@/node-types';
import { mockInstance } from '@test/mocking';
describe('CredentialsHelper', () => { describe('CredentialsHelper', () => {
mockInstance(CredentialsRepository); const nodeTypes = mock<INodeTypes>();
mockInstance(SharedCredentialsRepository); const mockNodesAndCredentials = mock<LoadNodesAndCredentials>();
const mockNodesAndCredentials = mockInstance(LoadNodesAndCredentials, {
loadedNodes: {
'test.set': {
sourcePath: '',
type: {
description: {
displayName: 'Set',
name: 'set',
group: ['input'],
version: 1,
description: 'Sets a value',
defaults: {
name: 'Set',
color: '#0000FF',
},
inputs: [NodeConnectionType.Main],
outputs: [NodeConnectionType.Main],
properties: [
{
displayName: 'Value1',
name: 'value1',
type: 'string',
default: 'default-value1',
},
{
displayName: 'Value2',
name: 'value2',
type: 'string',
default: 'default-value2',
},
],
},
},
},
},
});
const nodeTypes = mockInstance(NodeTypes); const credentialsHelper = new CredentialsHelper(
new CredentialTypes(mockNodesAndCredentials),
mock(),
mock(),
mock(),
mock(),
);
describe('authenticate', () => { describe('authenticate', () => {
const tests: Array<{ const tests: Array<{
@@ -272,19 +239,16 @@ describe('CredentialsHelper', () => {
for (const testData of tests) { for (const testData of tests) {
test(testData.description, async () => { test(testData.description, async () => {
//@ts-expect-error `loadedCredentials` is a getter and we are replacing it here with a property const { credentialType } = testData.input;
mockNodesAndCredentials.loadedCredentials = {
[testData.input.credentialType.name]: {
type: testData.input.credentialType,
sourcePath: '',
},
};
const credentialsHelper = Container.get(CredentialsHelper); mockNodesAndCredentials.getCredential.calledWith(credentialType.name).mockReturnValue({
type: credentialType,
sourcePath: '',
});
const result = await credentialsHelper.authenticate( const result = await credentialsHelper.authenticate(
testData.input.credentials, testData.input.credentials,
testData.input.credentialType.name, credentialType.name,
deepCopy(incomingRequestOptions), deepCopy(incomingRequestOptions),
workflow, workflow,
node, node,

View File

@@ -1,95 +1,111 @@
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { INodeType, IVersionedNodeType } from 'n8n-workflow'; import { UnrecognizedNodeTypeError } from 'n8n-core';
import type {
LoadedClass,
INodeType,
IVersionedNodeType,
INodeTypeDescription,
} from 'n8n-workflow';
import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { NodeTypes } from '@/node-types';
import { NodeTypes } from '../node-types';
describe('NodeTypes', () => { describe('NodeTypes', () => {
let nodeTypes: NodeTypes;
const loadNodesAndCredentials = mock<LoadNodesAndCredentials>(); const loadNodesAndCredentials = mock<LoadNodesAndCredentials>();
const nodeTypes: NodeTypes = new NodeTypes(loadNodesAndCredentials);
const nonVersionedNode: LoadedClass<INodeType> = {
sourcePath: '',
type: {
description: mock<INodeTypeDescription>({
name: 'n8n-nodes-base.nonVersioned',
usableAsTool: undefined,
}),
},
};
const v1Node = mock<INodeType>();
const v2Node = mock<INodeType>();
const versionedNode: LoadedClass<IVersionedNodeType> = {
sourcePath: '',
type: {
description: mock<INodeTypeDescription>({
name: 'n8n-nodes-base.versioned',
}),
currentVersion: 2,
nodeVersions: {
1: v1Node,
2: v2Node,
},
getNodeType(version) {
if (version === 1) return v1Node;
return v2Node;
},
},
};
const toolSupportingNode: LoadedClass<INodeType> = {
sourcePath: '',
type: {
description: mock<INodeTypeDescription>({
name: 'n8n-nodes-base.testNode',
displayName: 'TestNode',
usableAsTool: true,
properties: [],
}),
},
};
loadNodesAndCredentials.getNode.mockImplementation((fullNodeType) => {
const [packageName, nodeType] = fullNodeType.split('.');
if (nodeType === 'nonVersioned') return nonVersionedNode;
if (nodeType === 'versioned') return versionedNode;
if (nodeType === 'testNode') return toolSupportingNode;
throw new UnrecognizedNodeTypeError(packageName, nodeType);
});
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
nodeTypes = new NodeTypes(loadNodesAndCredentials); });
describe('getByName', () => {
it('should return node type when it exists', () => {
const result = nodeTypes.getByName('n8n-nodes-base.nonVersioned');
expect(result).toBe(nonVersionedNode.type);
});
}); });
describe('getByNameAndVersion', () => { describe('getByNameAndVersion', () => {
const nodeTypeName = 'n8n-nodes-base.testNode'; it('should throw an error if the package does not exist', () => {
expect(() => nodeTypes.getByNameAndVersion('invalid-package.unknownNode')).toThrow(
'Unrecognized node type: invalid-package.unknownNode',
);
});
it('should throw an error if the node-type does not exist', () => { it('should throw an error if the node-type does not exist', () => {
const nodeTypeName = 'unknownNode'; expect(() => nodeTypes.getByNameAndVersion('n8n-nodes-base.unknownNode')).toThrow(
'Unrecognized node type: n8n-nodes-base.unknownNode',
// @ts-expect-error overwriting a readonly property
loadNodesAndCredentials.loadedNodes = {};
// @ts-expect-error overwriting a readonly property
loadNodesAndCredentials.knownNodes = {};
expect(() => nodeTypes.getByNameAndVersion(nodeTypeName)).toThrow(
'Unrecognized node type: unknownNode',
); );
}); });
it('should return a regular node-type without version', () => { it('should return a regular node-type without version', () => {
const nodeType = mock<INodeType>(); const result = nodeTypes.getByNameAndVersion('n8n-nodes-base.nonVersioned');
expect(result).toBe(nonVersionedNode.type);
// @ts-expect-error overwriting a readonly property
loadNodesAndCredentials.loadedNodes = {
[nodeTypeName]: { type: nodeType },
};
const result = nodeTypes.getByNameAndVersion(nodeTypeName);
expect(result).toEqual(nodeType);
}); });
it('should return a regular node-type with version', () => { it('should return a regular node-type with version', () => {
const nodeTypeV1 = mock<INodeType>(); const result = nodeTypes.getByNameAndVersion('n8n-nodes-base.versioned');
const nodeType = mock<IVersionedNodeType>({ expect(result).toBe(v2Node);
nodeVersions: { 1: nodeTypeV1 },
getNodeType: () => nodeTypeV1,
});
// @ts-expect-error overwriting a readonly property
loadNodesAndCredentials.loadedNodes = {
[nodeTypeName]: { type: nodeType },
};
const result = nodeTypes.getByNameAndVersion(nodeTypeName);
expect(result).toEqual(nodeTypeV1);
}); });
it('should throw when a node-type is requested as tool, but does not support being used as one', () => { it('should throw when a node-type is requested as tool, but does not support being used as one', () => {
const nodeType = mock<INodeType>(); expect(() => nodeTypes.getByNameAndVersion('n8n-nodes-base.nonVersionedTool')).toThrow(
// @ts-expect-error overwriting a readonly property
loadNodesAndCredentials.loadedNodes = {
[nodeTypeName]: { type: nodeType },
};
expect(() => nodeTypes.getByNameAndVersion(`${nodeTypeName}Tool`)).toThrow(
'Node cannot be used as a tool', 'Node cannot be used as a tool',
); );
}); });
it('should return the tool node-type when requested as tool', () => { it('should return the tool node-type when requested as tool', () => {
const nodeType = mock<INodeType>(); const result = nodeTypes.getByNameAndVersion('n8n-nodes-base.testNodeTool');
// @ts-expect-error can't use a mock here expect(result).not.toEqual(toolSupportingNode);
nodeType.description = {
name: nodeTypeName,
displayName: 'TestNode',
usableAsTool: true,
properties: [],
};
// @ts-expect-error overwriting a readonly property
loadNodesAndCredentials.loadedNodes = {
[nodeTypeName]: { type: nodeType },
};
const result = nodeTypes.getByNameAndVersion(`${nodeTypeName}Tool`);
expect(result).not.toEqual(nodeType);
expect(result.description.name).toEqual('n8n-nodes-base.testNodeTool'); expect(result.description.name).toEqual('n8n-nodes-base.testNodeTool');
expect(result.description.displayName).toEqual('TestNode Tool'); expect(result.description.displayName).toEqual('TestNode Tool');
expect(result.description.codex?.categories).toContain('AI'); expect(result.description.codex?.categories).toContain('AI');
@@ -97,4 +113,47 @@ describe('NodeTypes', () => {
expect(result.description.outputs).toEqual(['ai_tool']); expect(result.description.outputs).toEqual(['ai_tool']);
}); });
}); });
describe('getWithSourcePath', () => {
it('should return description and source path for existing node', () => {
const result = nodeTypes.getWithSourcePath('n8n-nodes-base.nonVersioned', 1);
expect(result).toHaveProperty('description');
expect(result).toHaveProperty('sourcePath');
expect(result.sourcePath).toBe(nonVersionedNode.sourcePath);
});
it('should throw error for non-existent node', () => {
expect(() => nodeTypes.getWithSourcePath('n8n-nodes-base.nonExistent', 1)).toThrow(
'Unrecognized node type: n8n-nodes-base.nonExistent',
);
});
});
describe('getKnownTypes', () => {
it('should return known node types', () => {
// @ts-expect-error readonly property
loadNodesAndCredentials.knownNodes = ['n8n-nodes-base.nonVersioned'];
const result = nodeTypes.getKnownTypes();
expect(result).toEqual(['n8n-nodes-base.nonVersioned']);
});
});
describe('getNodeTypeDescriptions', () => {
it('should return descriptions for valid node types', () => {
const nodeTypes = new NodeTypes(loadNodesAndCredentials);
const result = nodeTypes.getNodeTypeDescriptions([
{ name: 'n8n-nodes-base.nonVersioned', version: 1 },
]);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('n8n-nodes-base.nonVersioned');
});
it('should throw error for invalid node type', () => {
const nodeTypes = new NodeTypes(loadNodesAndCredentials);
expect(() =>
nodeTypes.getNodeTypeDescriptions([{ name: 'n8n-nodes-base.nonExistent', version: 1 }]),
).toThrow('Unrecognized node type: n8n-nodes-base.nonExistent');
});
});
}); });

View File

@@ -1,13 +1,6 @@
import { loadClassInIsolation } from 'n8n-core'; import type { ICredentialType, ICredentialTypes } from 'n8n-workflow';
import {
ApplicationError,
type ICredentialType,
type ICredentialTypes,
type LoadedClass,
} from 'n8n-workflow';
import { Service } from 'typedi'; import { Service } from 'typedi';
import { RESPONSE_ERROR_MESSAGES } from '@/constants';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
@Service() @Service()
@@ -20,7 +13,7 @@ export class CredentialTypes implements ICredentialTypes {
} }
getByName(credentialType: string): ICredentialType { getByName(credentialType: string): ICredentialType {
return this.getCredential(credentialType).type; return this.loadNodesAndCredentials.getCredential(credentialType).type;
} }
getSupportedNodes(type: string): string[] { getSupportedNodes(type: string): string[] {
@@ -39,21 +32,4 @@ export class CredentialTypes implements ICredentialTypes {
} }
return extendsArr; return extendsArr;
} }
private getCredential(type: string): LoadedClass<ICredentialType> {
const { loadedCredentials, knownCredentials } = this.loadNodesAndCredentials;
if (type in loadedCredentials) {
return loadedCredentials[type];
}
if (type in knownCredentials) {
const { className, sourcePath } = knownCredentials[type];
const loaded: ICredentialType = loadClassInIsolation(sourcePath, className);
loadedCredentials[type] = { sourcePath, type: loaded };
return loadedCredentials[type];
}
throw new ApplicationError(RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL, {
tags: { credentialType: type },
});
}
} }

View File

@@ -35,7 +35,7 @@ export class InstalledPackagesRepository extends Repository<InstalledPackages> {
for (const loadedNode of loadedNodes) { for (const loadedNode of loadedNodes) {
const installedNode = this.installedNodesRepository.create({ const installedNode = this.installedNodesRepository.create({
name: nodeTypes[loadedNode.name].type.description.displayName, name: nodeTypes[loadedNode.name].type.description.displayName,
type: loadedNode.name, type: `${packageName}.${loadedNode.name}`,
latestVersion: loadedNode.version, latestVersion: loadedNode.version,
package: { packageName }, package: { packageName },
}); });

View File

@@ -8,6 +8,8 @@ import {
CustomDirectoryLoader, CustomDirectoryLoader,
PackageDirectoryLoader, PackageDirectoryLoader,
LazyPackageDirectoryLoader, LazyPackageDirectoryLoader,
UnrecognizedCredentialTypeError,
UnrecognizedNodeTypeError,
} from 'n8n-core'; } from 'n8n-core';
import type { import type {
KnownNodesAndCredentials, KnownNodesAndCredentials,
@@ -15,6 +17,10 @@ import type {
INodeTypeDescription, INodeTypeDescription,
INodeTypeData, INodeTypeData,
ICredentialTypeData, ICredentialTypeData,
LoadedClass,
ICredentialType,
INodeType,
IVersionedNodeType,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { NodeHelpers, ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; import { NodeHelpers, ApplicationError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import path from 'path'; import path from 'path';
@@ -307,13 +313,18 @@ export class LoadNodesAndCredentials {
for (const loader of Object.values(this.loaders)) { for (const loader of Object.values(this.loaders)) {
// list of node & credential types that will be sent to the frontend // list of node & credential types that will be sent to the frontend
const { known, types, directory } = loader; const { known, types, directory, packageName } = loader;
this.types.nodes = this.types.nodes.concat(types.nodes); this.types.nodes = this.types.nodes.concat(
types.nodes.map(({ name, ...rest }) => ({
...rest,
name: `${packageName}.${name}`,
})),
);
this.types.credentials = this.types.credentials.concat(types.credentials); this.types.credentials = this.types.credentials.concat(types.credentials);
// Nodes and credentials that have been loaded immediately // Nodes and credentials that have been loaded immediately
for (const nodeTypeName in loader.nodeTypes) { for (const nodeTypeName in loader.nodeTypes) {
this.loaded.nodes[nodeTypeName] = loader.nodeTypes[nodeTypeName]; this.loaded.nodes[`${packageName}.${nodeTypeName}`] = loader.nodeTypes[nodeTypeName];
} }
for (const credentialTypeName in loader.credentialTypes) { for (const credentialTypeName in loader.credentialTypes) {
@@ -322,7 +333,7 @@ export class LoadNodesAndCredentials {
for (const type in known.nodes) { for (const type in known.nodes) {
const { className, sourcePath } = known.nodes[type]; const { className, sourcePath } = known.nodes[type];
this.known.nodes[type] = { this.known.nodes[`${packageName}.${type}`] = {
className, className,
sourcePath: path.join(directory, sourcePath), sourcePath: path.join(directory, sourcePath),
}; };
@@ -356,6 +367,33 @@ export class LoadNodesAndCredentials {
} }
} }
getNode(fullNodeType: string): LoadedClass<INodeType | IVersionedNodeType> {
const [packageName, nodeType] = fullNodeType.split('.');
const { loaders } = this;
const loader = loaders[packageName];
if (!loader) {
throw new UnrecognizedNodeTypeError(packageName, nodeType);
}
return loader.getNode(nodeType);
}
getCredential(credentialType: string): LoadedClass<ICredentialType> {
const { loadedCredentials } = this;
for (const loader of Object.values(this.loaders)) {
if (credentialType in loader.known.credentials) {
const loaded = loader.getCredential(credentialType);
loadedCredentials[credentialType] = loaded;
}
}
if (credentialType in loadedCredentials) {
return loadedCredentials[credentialType];
}
throw new UnrecognizedCredentialTypeError(credentialType);
}
async setupHotReload() { async setupHotReload() {
const { default: debounce } = await import('lodash/debounce'); const { default: debounce } = await import('lodash/debounce');
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies

View File

@@ -1,26 +1,16 @@
import type { NeededNodeType } from '@n8n/task-runner'; import type { NeededNodeType } from '@n8n/task-runner';
import type { Dirent } from 'fs'; import type { Dirent } from 'fs';
import { readdir } from 'fs/promises'; import { readdir } from 'fs/promises';
import { loadClassInIsolation } from 'n8n-core'; import type { INodeType, INodeTypeDescription, INodeTypes, IVersionedNodeType } from 'n8n-workflow';
import type {
INodeType,
INodeTypeDescription,
INodeTypes,
IVersionedNodeType,
LoadedClass,
} from 'n8n-workflow';
import { ApplicationError, NodeHelpers } from 'n8n-workflow'; import { ApplicationError, NodeHelpers } from 'n8n-workflow';
import { join, dirname } from 'path'; import { join, dirname } from 'path';
import { Service } from 'typedi'; import { Service } from 'typedi';
import { UnrecognizedNodeTypeError } from './errors/unrecognized-node-type.error';
import { LoadNodesAndCredentials } from './load-nodes-and-credentials'; import { LoadNodesAndCredentials } from './load-nodes-and-credentials';
@Service() @Service()
export class NodeTypes implements INodeTypes { export class NodeTypes implements INodeTypes {
constructor(private loadNodesAndCredentials: LoadNodesAndCredentials) { constructor(private loadNodesAndCredentials: LoadNodesAndCredentials) {}
loadNodesAndCredentials.addPostProcessor(async () => this.applySpecialNodeParameters());
}
/** /**
* Variant of `getByNameAndVersion` that includes the node's source path, used to locate a node's translations. * Variant of `getByNameAndVersion` that includes the node's source path, used to locate a node's translations.
@@ -29,19 +19,14 @@ export class NodeTypes implements INodeTypes {
nodeTypeName: string, nodeTypeName: string,
version: number, version: number,
): { description: INodeTypeDescription } & { sourcePath: string } { ): { description: INodeTypeDescription } & { sourcePath: string } {
const nodeType = this.getNode(nodeTypeName); const nodeType = this.loadNodesAndCredentials.getNode(nodeTypeName);
if (!nodeType) {
throw new ApplicationError('Unknown node type', { tags: { nodeTypeName } });
}
const { description } = NodeHelpers.getVersionedNodeType(nodeType.type, version); const { description } = NodeHelpers.getVersionedNodeType(nodeType.type, version);
return { description: { ...description }, sourcePath: nodeType.sourcePath }; return { description: { ...description }, sourcePath: nodeType.sourcePath };
} }
getByName(nodeType: string): INodeType | IVersionedNodeType { getByName(nodeType: string): INodeType | IVersionedNodeType {
return this.getNode(nodeType).type; return this.loadNodesAndCredentials.getNode(nodeType).type;
} }
getByNameAndVersion(nodeType: string, version?: number): INodeType { getByNameAndVersion(nodeType: string, version?: number): INodeType {
@@ -52,7 +37,7 @@ export class NodeTypes implements INodeTypes {
nodeType = nodeType.replace(/Tool$/, ''); nodeType = nodeType.replace(/Tool$/, '');
} }
const node = this.getNode(nodeType); const node = this.loadNodesAndCredentials.getNode(nodeType);
const versionedNodeType = NodeHelpers.getVersionedNodeType(node.type, version); const versionedNodeType = NodeHelpers.getVersionedNodeType(node.type, version);
if (!toolRequested) return versionedNodeType; if (!toolRequested) return versionedNodeType;
@@ -79,36 +64,10 @@ export class NodeTypes implements INodeTypes {
return tool; return tool;
} }
/* Some nodeTypes need to get special parameters applied like the polling nodes the polling times */
applySpecialNodeParameters() {
for (const nodeTypeData of Object.values(this.loadNodesAndCredentials.loadedNodes)) {
const nodeType = NodeHelpers.getVersionedNodeType(nodeTypeData.type);
NodeHelpers.applySpecialNodeParameters(nodeType);
}
}
getKnownTypes() { getKnownTypes() {
return this.loadNodesAndCredentials.knownNodes; return this.loadNodesAndCredentials.knownNodes;
} }
private getNode(type: string): LoadedClass<INodeType | IVersionedNodeType> {
const { loadedNodes, knownNodes } = this.loadNodesAndCredentials;
if (type in loadedNodes) {
return loadedNodes[type];
}
if (type in knownNodes) {
const { className, sourcePath } = knownNodes[type];
const loaded: INodeType = loadClassInIsolation(sourcePath, className);
NodeHelpers.applySpecialNodeParameters(loaded);
loadedNodes[type] = { sourcePath, type: loaded };
return loadedNodes[type];
}
throw new UnrecognizedNodeTypeError(type);
}
async getNodeTranslationPath({ async getNodeTranslationPath({
nodeSourcePath, nodeSourcePath,
longNodeType, longNodeType,
@@ -153,14 +112,12 @@ export class NodeTypes implements INodeTypes {
getNodeTypeDescriptions(nodeTypes: NeededNodeType[]): INodeTypeDescription[] { getNodeTypeDescriptions(nodeTypes: NeededNodeType[]): INodeTypeDescription[] {
return nodeTypes.map(({ name: nodeTypeName, version: nodeTypeVersion }) => { return nodeTypes.map(({ name: nodeTypeName, version: nodeTypeVersion }) => {
const nodeType = this.getNode(nodeTypeName); const nodeType = this.loadNodesAndCredentials.getNode(nodeTypeName);
if (!nodeType) throw new ApplicationError(`Unknown node type: ${nodeTypeName}`);
const { description } = NodeHelpers.getVersionedNodeType(nodeType.type, nodeTypeVersion); const { description } = NodeHelpers.getVersionedNodeType(nodeType.type, nodeTypeVersion);
const descriptionCopy = { ...description }; const descriptionCopy = { ...description };
// TODO: do we still need this?
descriptionCopy.name = descriptionCopy.name.startsWith('n8n-nodes') descriptionCopy.name = descriptionCopy.name.startsWith('n8n-nodes')
? descriptionCopy.name ? descriptionCopy.name
: `n8n-nodes-base.${descriptionCopy.name}`; // nodes-base nodes are unprefixed : `n8n-nodes-base.${descriptionCopy.name}`; // nodes-base nodes are unprefixed

View File

@@ -1,6 +1,6 @@
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { InstanceSettings } from 'n8n-core'; import type { InstanceSettings } from 'n8n-core';
import { NodeApiError, NodeOperationError, Workflow } from 'n8n-workflow'; import { NodeApiError, Workflow } from 'n8n-workflow';
import type { IWebhookData, WorkflowActivateMode } from 'n8n-workflow'; import type { IWebhookData, WorkflowActivateMode } from 'n8n-workflow';
import { Container } from 'typedi'; import { Container } from 'typedi';
@@ -10,7 +10,6 @@ import type { WebhookEntity } from '@/databases/entities/webhook-entity';
import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity';
import { ExecutionService } from '@/executions/execution.service'; import { ExecutionService } from '@/executions/execution.service';
import { ExternalHooks } from '@/external-hooks'; import { ExternalHooks } from '@/external-hooks';
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
import { NodeTypes } from '@/node-types'; import { NodeTypes } from '@/node-types';
import { Push } from '@/push'; import { Push } from '@/push';
import { SecretsHelper } from '@/secrets-helpers'; import { SecretsHelper } from '@/secrets-helpers';
@@ -22,6 +21,7 @@ import { WorkflowService } from '@/workflows/workflow.service';
import { createOwner } from './shared/db/users'; import { createOwner } from './shared/db/users';
import { createWorkflow } from './shared/db/workflows'; import { createWorkflow } from './shared/db/workflows';
import * as testDb from './shared/test-db'; import * as testDb from './shared/test-db';
import * as utils from './shared/utils/';
import { mockInstance } from '../shared/mocking'; import { mockInstance } from '../shared/mocking';
mockInstance(ActiveExecutions); mockInstance(ActiveExecutions);
@@ -30,21 +30,6 @@ mockInstance(SecretsHelper);
mockInstance(ExecutionService); mockInstance(ExecutionService);
mockInstance(WorkflowService); mockInstance(WorkflowService);
const loader = mockInstance(LoadNodesAndCredentials);
Object.assign(loader.loadedNodes, {
'n8n-nodes-base.scheduleTrigger': {
type: {
description: {
displayName: 'Schedule Trigger',
name: 'scheduleTrigger',
properties: [],
},
trigger: async () => {},
},
},
});
const webhookService = mockInstance(WebhookService); const webhookService = mockInstance(WebhookService);
const externalHooks = mockInstance(ExternalHooks); const externalHooks = mockInstance(ExternalHooks);
@@ -58,15 +43,17 @@ beforeAll(async () => {
activeWorkflowManager = Container.get(ActiveWorkflowManager); activeWorkflowManager = Container.get(ActiveWorkflowManager);
await utils.initNodeTypes();
const owner = await createOwner(); const owner = await createOwner();
createActiveWorkflow = async () => await createWorkflow({ active: true }, owner); createActiveWorkflow = async () => await createWorkflow({ active: true }, owner);
createInactiveWorkflow = async () => await createWorkflow({ active: false }, owner); createInactiveWorkflow = async () => await createWorkflow({ active: false }, owner);
}); });
afterEach(async () => { afterEach(async () => {
await testDb.truncate(['Workflow', 'Webhook']);
await activeWorkflowManager.removeAll(); await activeWorkflowManager.removeAll();
jest.restoreAllMocks(); await testDb.truncate(['Workflow', 'Webhook']);
jest.clearAllMocks();
}); });
afterAll(async () => { afterAll(async () => {
@@ -206,22 +193,22 @@ describe('remove()', () => {
}); });
describe('executeErrorWorkflow()', () => { describe('executeErrorWorkflow()', () => {
it('should delegate to `WorkflowExecuteAdditionalData`', async () => { // it('should delegate to `WorkflowExecuteAdditionalData`', async () => {
const dbWorkflow = await createActiveWorkflow(); // const dbWorkflow = await createActiveWorkflow();
const [node] = dbWorkflow.nodes; // const [node] = dbWorkflow.nodes;
const executeSpy = jest.spyOn(AdditionalData, 'executeErrorWorkflow'); // const executeSpy = jest.spyOn(AdditionalData, 'executeErrorWorkflow');
await activeWorkflowManager.init(); // await activeWorkflowManager.init();
activeWorkflowManager.executeErrorWorkflow( // activeWorkflowManager.executeErrorWorkflow(
new NodeOperationError(node, 'Something went wrong'), // new NodeOperationError(node, 'Something went wrong'),
dbWorkflow, // dbWorkflow,
'trigger', // 'trigger',
); // );
expect(executeSpy).toHaveBeenCalledTimes(1); // expect(executeSpy).toHaveBeenCalledTimes(1);
}); // });
it('should be called on failure to activate due to 401', async () => { it('should be called on failure to activate due to 401', async () => {
const dbWorkflow = await createActiveWorkflow(); const dbWorkflow = await createActiveWorkflow();

View File

@@ -528,8 +528,28 @@ describe('POST /workflows/:id/activate', () => {
expect(response.statusCode).toBe(404); expect(response.statusCode).toBe(404);
}); });
test('should fail due to trying to activate a workflow without any nodes', async () => {
const workflow = await createWorkflow({ nodes: [] }, owner);
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`);
expect(response.statusCode).toBe(400);
});
test('should fail due to trying to activate a workflow without a trigger', async () => { test('should fail due to trying to activate a workflow without a trigger', async () => {
const workflow = await createWorkflow({}, owner); const workflow = await createWorkflow(
{
nodes: [
{
id: 'uuid-1234',
name: 'Start',
parameters: {},
position: [-20, 260],
type: 'n8n-nodes-base.start',
typeVersion: 1,
},
],
},
owner,
);
const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`); const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`);
expect(response.statusCode).toBe(400); expect(response.statusCode).toBe(400);
}); });

View File

@@ -22,7 +22,7 @@ export const mockNode = (packageName: string) => {
return Container.get(InstalledNodesRepository).create({ return Container.get(InstalledNodesRepository).create({
name: nodeName, name: nodeName,
type: nodeName, type: `${packageName}.${nodeName}`,
latestVersion: COMMUNITY_NODE_VERSION.CURRENT, latestVersion: COMMUNITY_NODE_VERSION.CURRENT,
package: { packageName }, package: { packageName },
}); });

View File

@@ -1,10 +1,12 @@
import { BinaryDataService } from 'n8n-core'; import { mock } from 'jest-mock-extended';
import { BinaryDataService, UnrecognizedNodeTypeError, type DirectoryLoader } from 'n8n-core';
import { Ftp } from 'n8n-nodes-base/credentials/Ftp.credentials'; import { Ftp } from 'n8n-nodes-base/credentials/Ftp.credentials';
import { GithubApi } from 'n8n-nodes-base/credentials/GithubApi.credentials'; import { GithubApi } from 'n8n-nodes-base/credentials/GithubApi.credentials';
import { Cron } from 'n8n-nodes-base/nodes/Cron/Cron.node'; import { Cron } from 'n8n-nodes-base/nodes/Cron/Cron.node';
import { ScheduleTrigger } from 'n8n-nodes-base/nodes/Schedule/ScheduleTrigger.node';
import { Set } from 'n8n-nodes-base/nodes/Set/Set.node'; import { Set } from 'n8n-nodes-base/nodes/Set/Set.node';
import { Start } from 'n8n-nodes-base/nodes/Start/Start.node'; import { Start } from 'n8n-nodes-base/nodes/Start/Start.node';
import { type INode } from 'n8n-workflow'; import type { INodeTypeData, INode } from 'n8n-workflow';
import type request from 'supertest'; import type request from 'supertest';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
@@ -62,7 +64,8 @@ export async function initCredentialsTypes(): Promise<void> {
* Initialize node types. * Initialize node types.
*/ */
export async function initNodeTypes() { export async function initNodeTypes() {
Container.get(LoadNodesAndCredentials).loaded.nodes = { ScheduleTrigger.prototype.trigger = async () => ({});
const nodes: INodeTypeData = {
'n8n-nodes-base.start': { 'n8n-nodes-base.start': {
type: new Start(), type: new Start(),
sourcePath: '', sourcePath: '',
@@ -75,7 +78,21 @@ export async function initNodeTypes() {
type: new Set(), type: new Set(),
sourcePath: '', sourcePath: '',
}, },
'n8n-nodes-base.scheduleTrigger': {
type: new ScheduleTrigger(),
sourcePath: '',
},
}; };
const loader = mock<DirectoryLoader>();
loader.getNode.mockImplementation((nodeType) => {
const node = nodes[`n8n-nodes-base.${nodeType}`];
if (!node) throw new UnrecognizedNodeTypeError('n8n-nodes-base', nodeType);
return node;
});
const loadNodesAndCredentials = Container.get(LoadNodesAndCredentials);
loadNodesAndCredentials.loaders = { 'n8n-nodes-base': loader };
loadNodesAndCredentials.loaded.nodes = nodes;
} }
/** /**

View File

@@ -1,99 +0,0 @@
#!/usr/bin/env node
const path = require('path');
const glob = require('fast-glob');
const uniq = require('lodash/uniq');
const { LoggerProxy, getCredentialsForNode } = require('n8n-workflow');
const { packageDir, writeJSON } = require('./common');
const { loadClassInIsolation } = require('../dist/ClassLoader');
LoggerProxy.init(console);
const loadClass = (sourcePath) => {
try {
const [className] = path.parse(sourcePath).name.split('.');
const filePath = path.resolve(packageDir, sourcePath);
const instance = loadClassInIsolation(filePath, className);
return { instance, sourcePath, className };
} catch (e) {
LoggerProxy.warn(`Failed to load ${sourcePath}: ${e.message}`);
}
};
const generateKnownNodes = async () => {
const nodeClasses = glob
.sync('dist/nodes/**/*.node.js', { cwd: packageDir })
.map(loadClass)
// Ignore node versions
.filter((nodeClass) => nodeClass && !/[vV]\d.node\.js$/.test(nodeClass.sourcePath));
const nodes = {};
const nodesByCredential = {};
for (const { className, sourcePath, instance } of nodeClasses) {
const nodeName = instance.description.name;
nodes[nodeName] = { className, sourcePath };
for (const credential of getCredentialsForNode(instance)) {
if (!nodesByCredential[credential.name]) {
nodesByCredential[credential.name] = [];
}
nodesByCredential[credential.name].push(nodeName);
}
}
LoggerProxy.info(`Detected ${Object.keys(nodes).length} nodes`);
await writeJSON('known/nodes.json', nodes);
return { nodes, nodesByCredential };
};
const generateKnownCredentials = async (nodesByCredential) => {
const credentialClasses = glob
.sync(`dist/credentials/**/*.credentials.js`, { cwd: packageDir })
.map(loadClass)
.filter((data) => !!data);
for (const { instance } of credentialClasses) {
if (Array.isArray(instance.extends)) {
for (const extendedCredential of instance.extends) {
nodesByCredential[extendedCredential] = [
...(nodesByCredential[extendedCredential] ?? []),
...(nodesByCredential[instance.name] ?? []),
];
}
}
}
const credentials = credentialClasses.reduce(
(credentials, { className, sourcePath, instance }) => {
const credentialName = instance.name;
const credential = {
className,
sourcePath,
};
if (Array.isArray(instance.extends)) {
credential.extends = instance.extends;
}
if (nodesByCredential[credentialName]) {
credential.supportedNodes = Array.from(new Set(nodesByCredential[credentialName]));
}
credentials[credentialName] = credential;
return credentials;
},
{},
);
LoggerProxy.info(`Detected ${Object.keys(credentials).length} credentials`);
await writeJSON('known/credentials.json', credentials);
return credentials;
};
(async () => {
const { nodesByCredential } = await generateKnownNodes();
await generateKnownCredentials(nodesByCredential);
})();

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env node #!/usr/bin/env node
const { LoggerProxy, NodeHelpers } = require('n8n-workflow'); const { LoggerProxy } = require('n8n-workflow');
const { PackageDirectoryLoader } = require('../dist/DirectoryLoader'); const { PackageDirectoryLoader } = require('../dist/DirectoryLoader');
const { packageDir, writeJSON } = require('./common'); const { packageDir, writeJSON } = require('./common');
@@ -33,7 +33,7 @@ function findReferencedMethods(obj, refs = {}, latestName = '') {
const loaderNodeTypes = Object.values(loader.nodeTypes); const loaderNodeTypes = Object.values(loader.nodeTypes);
const definedMethods = loaderNodeTypes.reduce((acc, cur) => { const definedMethods = loaderNodeTypes.reduce((acc, cur) => {
NodeHelpers.getVersionedNodeTypeAll(cur.type).forEach((type) => { loader.getVersionedNodeTypeAll(cur.type).forEach((type) => {
const methods = type.description?.__loadOptionsMethods; const methods = type.description?.__loadOptionsMethods;
if (!methods) return; if (!methods) return;
@@ -52,51 +52,21 @@ function findReferencedMethods(obj, refs = {}, latestName = '') {
}, {}); }, {});
const nodeTypes = loaderNodeTypes const nodeTypes = loaderNodeTypes
.map((data) => { .map(({ type }) => type)
const nodeType = NodeHelpers.getVersionedNodeType(data.type);
NodeHelpers.applySpecialNodeParameters(nodeType);
return data.type;
})
.flatMap((nodeType) => .flatMap((nodeType) =>
NodeHelpers.getVersionedNodeTypeAll(nodeType).map((item) => { loader.getVersionedNodeTypeAll(nodeType).map((item) => {
const { __loadOptionsMethods, ...rest } = item.description; const { __loadOptionsMethods, ...rest } = item.description;
return rest; return rest;
}), }),
); );
const knownCredentials = loader.known.credentials; const knownCredentials = loader.known.credentials;
const credentialTypes = Object.values(loader.credentialTypes).map((data) => { const credentialTypes = Object.values(loader.credentialTypes).map(({ type }) => type);
const credentialType = data.type;
const supportedNodes = knownCredentials[credentialType.name].supportedNodes ?? [];
if (supportedNodes.length > 0 && credentialType.httpRequestNode) {
credentialType.httpRequestNode.hidden = true;
}
credentialType.supportedNodes = supportedNodes;
if (!credentialType.iconUrl && !credentialType.icon) {
for (const supportedNode of supportedNodes) {
const nodeType = loader.nodeTypes[supportedNode]?.type.description;
if (!nodeType) continue;
if (nodeType.icon) {
credentialType.icon = nodeType.icon;
credentialType.iconColor = nodeType.iconColor;
break;
}
if (nodeType.iconUrl) {
credentialType.iconUrl = nodeType.iconUrl;
break;
}
}
}
return credentialType;
});
const referencedMethods = findReferencedMethods(nodeTypes); const referencedMethods = findReferencedMethods(nodeTypes);
await Promise.all([ await Promise.all([
writeJSON('known/nodes.json', loader.known.nodes),
writeJSON('known/credentials.json', loader.known.credentials),
writeJSON('types/credentials.json', credentialTypes), writeJSON('types/credentials.json', credentialTypes),
writeJSON('types/nodes.json', nodeTypes), writeJSON('types/nodes.json', nodeTypes),
writeJSON('methods/defined.json', definedMethods), writeJSON('methods/defined.json', definedMethods),

View File

@@ -6,9 +6,8 @@
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"bin": { "bin": {
"n8n-copy-icons": "./bin/copy-icons", "n8n-copy-icons": "./bin/copy-icons",
"n8n-generate-known": "./bin/generate-known",
"n8n-generate-translations": "./bin/generate-translations", "n8n-generate-translations": "./bin/generate-translations",
"n8n-generate-ui-types": "./bin/generate-ui-types" "n8n-generate-metadata": "./bin/generate-metadata"
}, },
"scripts": { "scripts": {
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",

View File

@@ -1,9 +1,11 @@
import glob from 'fast-glob'; import glob from 'fast-glob';
import uniqBy from 'lodash/uniqBy';
import type { import type {
CodexData, CodexData,
DocumentationLink, DocumentationLink,
ICredentialType, ICredentialType,
ICredentialTypeData, ICredentialTypeData,
INodeCredentialDescription,
INodeType, INodeType,
INodeTypeBaseDescription, INodeTypeBaseDescription,
INodeTypeData, INodeTypeData,
@@ -12,19 +14,15 @@ import type {
IVersionedNodeType, IVersionedNodeType,
KnownNodesAndCredentials, KnownNodesAndCredentials,
} from 'n8n-workflow'; } from 'n8n-workflow';
import { import { ApplicationError, LoggerProxy as Logger, NodeHelpers, jsonParse } from 'n8n-workflow';
ApplicationError,
LoggerProxy as Logger,
getCredentialsForNode,
getVersionedNodeTypeAll,
jsonParse,
} from 'n8n-workflow';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import * as path from 'path'; import * as path from 'path';
import { loadClassInIsolation } from './ClassLoader'; import { loadClassInIsolation } from './ClassLoader';
import { CUSTOM_NODES_CATEGORY } from './Constants'; import { CUSTOM_NODES_CATEGORY } from './Constants';
import { UnrecognizedCredentialTypeError } from './errors/unrecognized-credential-type.error';
import { UnrecognizedNodeTypeError } from './errors/unrecognized-node-type.error';
import type { n8n } from './Interfaces'; import type { n8n } from './Interfaces';
function toJSON(this: ICredentialType) { function toJSON(this: ICredentialType) {
@@ -34,11 +32,25 @@ function toJSON(this: ICredentialType) {
}; };
} }
type Codex = {
categories: string[];
subcategories: { [subcategory: string]: string[] };
resources: {
primaryDocumentation: DocumentationLink[];
credentialDocumentation: DocumentationLink[];
};
alias: string[];
};
export type Types = { export type Types = {
nodes: INodeTypeBaseDescription[]; nodes: INodeTypeBaseDescription[];
credentials: ICredentialType[]; credentials: ICredentialType[];
}; };
/**
* Base class for loading n8n nodes and credentials from a directory.
* Handles the common functionality for resolving paths, loading classes, and managing node and credential types.
*/
export abstract class DirectoryLoader { export abstract class DirectoryLoader {
isLazyLoaded = false; isLazyLoaded = false;
@@ -58,7 +70,7 @@ export abstract class DirectoryLoader {
// Stores the different versions with their individual descriptions // Stores the different versions with their individual descriptions
types: Types = { nodes: [], credentials: [] }; types: Types = { nodes: [], credentials: [] };
protected nodesByCredential: Record<string, string[]> = {}; readonly nodesByCredential: Record<string, string[]> = {};
constructor( constructor(
readonly directory: string, readonly directory: string,
@@ -82,35 +94,40 @@ export abstract class DirectoryLoader {
return path.resolve(this.directory, file); return path.resolve(this.directory, file);
} }
protected loadNodeFromFile(nodeName: string, filePath: string) { private loadClass<T>(sourcePath: string) {
let tempNode: INodeType | IVersionedNodeType; const filePath = this.resolvePath(sourcePath);
let nodeVersion = 1; const [className] = path.parse(sourcePath).name.split('.');
const isCustom = this.packageName === 'CUSTOM';
try { try {
tempNode = loadClassInIsolation(filePath, nodeName); return loadClassInIsolation<T>(filePath, className);
this.addCodex({ node: tempNode, filePath, isCustom });
} catch (error) { } catch (error) {
Logger.error( throw error instanceof TypeError
`Error loading node "${nodeName}" from: "${filePath}" - ${(error as Error).message}`, ? new ApplicationError(
); 'Class could not be found. Please check if the class is named correctly.',
throw error; { extra: { className } },
)
: error;
} }
}
const fullNodeName = `${this.packageName}.${tempNode.description.name}`; /** Loads a nodes class from a file, fixes icons, and augments the codex */
loadNodeFromFile(filePath: string) {
const tempNode = this.loadClass<INodeType | IVersionedNodeType>(filePath);
this.addCodex(tempNode, filePath);
if (this.includeNodes.length && !this.includeNodes.includes(fullNodeName)) { const nodeType = tempNode.description.name;
const fullNodeType = `${this.packageName}.${nodeType}`;
if (this.includeNodes.length && !this.includeNodes.includes(fullNodeType)) {
return; return;
} }
if (this.excludeNodes.includes(fullNodeName)) { if (this.excludeNodes.includes(fullNodeType)) {
return; return;
} }
tempNode.description.name = fullNodeName;
this.fixIconPaths(tempNode.description, filePath); this.fixIconPaths(tempNode.description, filePath);
let nodeVersion = 1;
if ('nodeVersions' in tempNode) { if ('nodeVersions' in tempNode) {
for (const versionNode of Object.values(tempNode.nodeVersions)) { for (const versionNode of Object.values(tempNode.nodeVersions)) {
this.fixIconPaths(versionNode.description, filePath); this.fixIconPaths(versionNode.description, filePath);
@@ -118,85 +135,93 @@ export abstract class DirectoryLoader {
for (const version of Object.values(tempNode.nodeVersions)) { for (const version of Object.values(tempNode.nodeVersions)) {
this.addLoadOptionsMethods(version); this.addLoadOptionsMethods(version);
NodeHelpers.applySpecialNodeParameters(version);
} }
const currentVersionNode = tempNode.nodeVersions[tempNode.currentVersion]; const currentVersionNode = tempNode.nodeVersions[tempNode.currentVersion];
this.addCodex({ node: currentVersionNode, filePath, isCustom }); this.addCodex(currentVersionNode, filePath);
nodeVersion = tempNode.currentVersion; nodeVersion = tempNode.currentVersion;
if (currentVersionNode.hasOwnProperty('executeSingle')) { if (currentVersionNode.hasOwnProperty('executeSingle')) {
throw new ApplicationError( throw new ApplicationError(
'"executeSingle" has been removed. Please update the code of this node to use "execute" instead.', '"executeSingle" has been removed. Please update the code of this node to use "execute" instead.',
{ extra: { nodeName: `${this.packageName}.${nodeName}` } }, { extra: { nodeType: fullNodeType } },
); );
} }
} else { } else {
this.addLoadOptionsMethods(tempNode); this.addLoadOptionsMethods(tempNode);
// Short renaming to avoid type issues NodeHelpers.applySpecialNodeParameters(tempNode);
// Short renaming to avoid type issues
nodeVersion = Array.isArray(tempNode.description.version) nodeVersion = Array.isArray(tempNode.description.version)
? tempNode.description.version.slice(-1)[0] ? tempNode.description.version.slice(-1)[0]
: tempNode.description.version; : tempNode.description.version;
} }
this.known.nodes[fullNodeName] = { this.known.nodes[nodeType] = {
className: nodeName, className: tempNode.constructor.name,
sourcePath: filePath, sourcePath: filePath,
}; };
this.nodeTypes[fullNodeName] = { this.nodeTypes[nodeType] = {
type: tempNode, type: tempNode,
sourcePath: filePath, sourcePath: filePath,
}; };
this.loadedNodes.push({ this.loadedNodes.push({
name: fullNodeName, name: nodeType,
version: nodeVersion, version: nodeVersion,
}); });
getVersionedNodeTypeAll(tempNode).forEach(({ description }) => { this.getVersionedNodeTypeAll(tempNode).forEach(({ description }) => {
this.types.nodes.push(description); this.types.nodes.push(description);
}); });
for (const credential of getCredentialsForNode(tempNode)) { for (const credential of this.getCredentialsForNode(tempNode)) {
if (!this.nodesByCredential[credential.name]) { if (!this.nodesByCredential[credential.name]) {
this.nodesByCredential[credential.name] = []; this.nodesByCredential[credential.name] = [];
} }
this.nodesByCredential[credential.name].push(fullNodeName); this.nodesByCredential[credential.name].push(nodeType);
} }
} }
protected loadCredentialFromFile(credentialClassName: string, filePath: string): void { getNode(nodeType: string) {
let tempCredential: ICredentialType; const {
try { nodeTypes,
tempCredential = loadClassInIsolation(filePath, credentialClassName); known: { nodes: knownNodes },
} = this;
// Add serializer method "toJSON" to the class so that authenticate method (if defined) if (!(nodeType in nodeTypes) && nodeType in knownNodes) {
// gets mapped to the authenticate attribute before it is sent to the client. const { sourcePath } = knownNodes[nodeType];
// The authenticate property is used by the client to decide whether or not to this.loadNodeFromFile(sourcePath);
// include the credential type in the predefined credentials (HTTP node)
Object.assign(tempCredential, { toJSON });
this.fixIconPaths(tempCredential, filePath);
} catch (e) {
if (e instanceof TypeError) {
throw new ApplicationError(
'Class could not be found. Please check if the class is named correctly.',
{ extra: { credentialClassName } },
);
} else {
throw e;
}
} }
this.known.credentials[tempCredential.name] = { if (nodeType in nodeTypes) {
className: credentialClassName, return nodeTypes[nodeType];
}
throw new UnrecognizedNodeTypeError(this.packageName, nodeType);
}
/** Loads a credential class from a file, and fixes icons */
loadCredentialFromFile(filePath: string): void {
const tempCredential = this.loadClass<ICredentialType>(filePath);
// Add serializer method "toJSON" to the class so that authenticate method (if defined)
// gets mapped to the authenticate attribute before it is sent to the client.
// The authenticate property is used by the client to decide whether or not to
// include the credential type in the predefined credentials (HTTP node)
Object.assign(tempCredential, { toJSON });
this.fixIconPaths(tempCredential, filePath);
const credentialType = tempCredential.name;
this.known.credentials[credentialType] = {
className: tempCredential.constructor.name,
sourcePath: filePath, sourcePath: filePath,
extends: tempCredential.extends, extends: tempCredential.extends,
supportedNodes: this.nodesByCredential[tempCredential.name], supportedNodes: this.nodesByCredential[credentialType],
}; };
this.credentialTypes[tempCredential.name] = { this.credentialTypes[credentialType] = {
type: tempCredential, type: tempCredential,
sourcePath: filePath, sourcePath: filePath,
}; };
@@ -204,40 +229,79 @@ export abstract class DirectoryLoader {
this.types.credentials.push(tempCredential); this.types.credentials.push(tempCredential);
} }
getCredential(credentialType: string) {
const {
credentialTypes,
known: { credentials: knownCredentials },
} = this;
if (!(credentialType in credentialTypes) && credentialType in knownCredentials) {
const { sourcePath } = knownCredentials[credentialType];
this.loadCredentialFromFile(sourcePath);
}
if (credentialType in credentialTypes) {
return credentialTypes[credentialType];
}
throw new UnrecognizedCredentialTypeError(credentialType);
}
/**
* Returns an array of credential descriptions that are supported by a node.
* For versioned nodes, combines and deduplicates credentials from all versions.
*/
getCredentialsForNode(object: IVersionedNodeType | INodeType): INodeCredentialDescription[] {
if ('nodeVersions' in object) {
const credentials = Object.values(object.nodeVersions).flatMap(
({ description }) => description.credentials ?? [],
);
return uniqBy(credentials, 'name');
}
return object.description.credentials ?? [];
}
/**
* Returns an array of all versions of a node type.
* For non-versioned nodes, returns an array with just that node.
* For versioned nodes, returns all available versions.
*/
getVersionedNodeTypeAll(object: IVersionedNodeType | INodeType): INodeType[] {
if ('nodeVersions' in object) {
const nodeVersions = Object.values(object.nodeVersions).map((element) => {
element.description.name = object.description.name;
element.description.codex = object.description.codex;
return element;
});
return uniqBy(nodeVersions.reverse(), (node) => {
const { version } = node.description;
return Array.isArray(version) ? version.join(',') : version.toString();
});
}
return [object];
}
/** /**
* Retrieves `categories`, `subcategories` and alias (if defined) * Retrieves `categories`, `subcategories` and alias (if defined)
* from the codex data for the node at the given file path. * from the codex data for the node at the given file path.
*/ */
private getCodex(filePath: string): CodexData { private getCodex(filePath: string): CodexData {
type Codex = { const codexFilePath = this.resolvePath(`${filePath}on`); // .js to .json
categories: string[];
subcategories: { [subcategory: string]: string[] };
resources: {
primaryDocumentation: DocumentationLink[];
credentialDocumentation: DocumentationLink[];
};
alias: string[];
};
const codexFilePath = `${filePath}on`; // .js to .json
const { const {
categories, categories,
subcategories, subcategories,
resources: allResources, resources: { primaryDocumentation, credentialDocumentation },
alias, alias,
} = module.require(codexFilePath) as Codex; } = module.require(codexFilePath) as Codex;
const resources = {
primaryDocumentation: allResources.primaryDocumentation,
credentialDocumentation: allResources.credentialDocumentation,
};
return { return {
...(categories && { categories }), ...(categories && { categories }),
...(subcategories && { subcategories }), ...(subcategories && { subcategories }),
...(resources && { resources }),
...(alias && { alias }), ...(alias && { alias }),
resources: {
primaryDocumentation,
credentialDocumentation,
},
}; };
} }
@@ -245,15 +309,8 @@ export abstract class DirectoryLoader {
* Adds a node codex `categories` and `subcategories` (if defined) * Adds a node codex `categories` and `subcategories` (if defined)
* to a node description `codex` property. * to a node description `codex` property.
*/ */
private addCodex({ private addCodex(node: INodeType | IVersionedNodeType, filePath: string) {
node, const isCustom = this.packageName === 'CUSTOM';
filePath,
isCustom,
}: {
node: INodeType | IVersionedNodeType;
filePath: string;
isCustom: boolean;
}) {
try { try {
let codex; let codex;
@@ -273,7 +330,7 @@ export abstract class DirectoryLoader {
node.description.codex = codex; node.description.codex = codex;
} catch { } catch {
Logger.debug(`No codex available for: ${filePath.split('/').pop() ?? ''}`); Logger.debug(`No codex available for: ${node.description.name}`);
if (isCustom) { if (isCustom) {
node.description.codex = { node.description.codex = {
@@ -291,8 +348,7 @@ export abstract class DirectoryLoader {
private getIconPath(icon: string, filePath: string) { private getIconPath(icon: string, filePath: string) {
const iconPath = path.join(path.dirname(filePath), icon.replace('file:', '')); const iconPath = path.join(path.dirname(filePath), icon.replace('file:', ''));
const relativePath = path.relative(this.directory, iconPath); return `icons/${this.packageName}/${iconPath}`;
return `icons/${this.packageName}/${relativePath}`;
} }
private fixIconPaths( private fixIconPaths(
@@ -305,14 +361,14 @@ export abstract class DirectoryLoader {
if (typeof icon === 'string') { if (typeof icon === 'string') {
if (icon.startsWith('file:')) { if (icon.startsWith('file:')) {
obj.iconUrl = this.getIconPath(icon, filePath); obj.iconUrl = this.getIconPath(icon, filePath);
delete obj.icon; obj.icon = undefined;
} }
} else if (icon.light.startsWith('file:') && icon.dark.startsWith('file:')) { } else if (icon.light.startsWith('file:') && icon.dark.startsWith('file:')) {
obj.iconUrl = { obj.iconUrl = {
light: this.getIconPath(icon.light, filePath), light: this.getIconPath(icon.light, filePath),
dark: this.getIconPath(icon.dark, filePath), dark: this.getIconPath(icon.dark, filePath),
}; };
delete obj.icon; obj.icon = undefined;
} }
} }
} }
@@ -331,8 +387,7 @@ export class CustomDirectoryLoader extends DirectoryLoader {
}); });
for (const nodePath of nodes) { for (const nodePath of nodes) {
const [fileName] = path.parse(nodePath).name.split('.'); this.loadNodeFromFile(nodePath);
this.loadNodeFromFile(fileName, nodePath);
} }
const credentials = await glob('**/*.credentials.js', { const credentials = await glob('**/*.credentials.js', {
@@ -341,8 +396,7 @@ export class CustomDirectoryLoader extends DirectoryLoader {
}); });
for (const credentialPath of credentials) { for (const credentialPath of credentials) {
const [fileName] = path.parse(credentialPath).name.split('.'); this.loadCredentialFromFile(credentialPath);
this.loadCredentialFromFile(fileName, credentialPath);
} }
} }
} }
@@ -363,33 +417,55 @@ export class PackageDirectoryLoader extends DirectoryLoader {
const { nodes, credentials } = n8n; const { nodes, credentials } = n8n;
if (Array.isArray(nodes)) { if (Array.isArray(nodes)) {
for (const node of nodes) { for (const nodePath of nodes) {
const filePath = this.resolvePath(node); this.loadNodeFromFile(nodePath);
const [nodeName] = path.parse(node).name.split('.');
this.loadNodeFromFile(nodeName, filePath);
} }
} }
if (Array.isArray(credentials)) { if (Array.isArray(credentials)) {
for (const credential of credentials) { for (const credentialPath of credentials) {
const filePath = this.resolvePath(credential); this.loadCredentialFromFile(credentialPath);
const [credentialName] = path.parse(credential).name.split('.');
this.loadCredentialFromFile(credentialName, filePath);
} }
} }
this.inferSupportedNodes();
Logger.debug(`Loaded all credentials and nodes from ${this.packageName}`, { Logger.debug(`Loaded all credentials and nodes from ${this.packageName}`, {
credentials: credentials?.length ?? 0, credentials: credentials?.length ?? 0,
nodes: nodes?.length ?? 0, nodes: nodes?.length ?? 0,
}); });
} }
protected readJSONSync<T>(file: string): T { private inferSupportedNodes() {
const filePath = this.resolvePath(file); const knownCredentials = this.known.credentials;
const fileString = readFileSync(filePath, 'utf8'); for (const { type: credentialType } of Object.values(this.credentialTypes)) {
const supportedNodes = knownCredentials[credentialType.name].supportedNodes ?? [];
if (supportedNodes.length > 0 && credentialType.httpRequestNode) {
credentialType.httpRequestNode.hidden = true;
}
credentialType.supportedNodes = supportedNodes;
if (!credentialType.iconUrl && !credentialType.icon) {
for (const supportedNode of supportedNodes) {
const nodeDescription = this.nodeTypes[supportedNode]?.type.description;
if (!nodeDescription) continue;
if (nodeDescription.icon) {
credentialType.icon = nodeDescription.icon;
credentialType.iconColor = nodeDescription.iconColor;
break;
}
if (nodeDescription.iconUrl) {
credentialType.iconUrl = nodeDescription.iconUrl;
break;
}
}
}
}
}
private parseJSON<T>(fileString: string, filePath: string): T {
try { try {
return jsonParse<T>(fileString); return jsonParse<T>(fileString);
} catch (error) { } catch (error) {
@@ -397,15 +473,16 @@ export class PackageDirectoryLoader extends DirectoryLoader {
} }
} }
protected readJSONSync<T>(file: string): T {
const filePath = this.resolvePath(file);
const fileString = readFileSync(filePath, 'utf8');
return this.parseJSON<T>(fileString, filePath);
}
protected async readJSON<T>(file: string): Promise<T> { protected async readJSON<T>(file: string): Promise<T> {
const filePath = this.resolvePath(file); const filePath = this.resolvePath(file);
const fileString = await readFile(filePath, 'utf8'); const fileString = await readFile(filePath, 'utf8');
return this.parseJSON<T>(fileString, filePath);
try {
return jsonParse<T>(fileString);
} catch (error) {
throw new ApplicationError('Failed to parse JSON', { extra: { filePath } });
}
} }
} }
@@ -415,10 +492,7 @@ export class PackageDirectoryLoader extends DirectoryLoader {
export class LazyPackageDirectoryLoader extends PackageDirectoryLoader { export class LazyPackageDirectoryLoader extends PackageDirectoryLoader {
override async loadAll() { override async loadAll() {
try { try {
const knownNodes: typeof this.known.nodes = await this.readJSON('dist/known/nodes.json'); this.known.nodes = await this.readJSON('dist/known/nodes.json');
for (const nodeName in knownNodes) {
this.known.nodes[`${this.packageName}.${nodeName}`] = knownNodes[nodeName];
}
this.known.credentials = await this.readJSON('dist/known/credentials.json'); this.known.credentials = await this.readJSON('dist/known/credentials.json');
this.types.nodes = await this.readJSON('dist/types/nodes.json'); this.types.nodes = await this.readJSON('dist/types/nodes.json');
@@ -426,9 +500,10 @@ export class LazyPackageDirectoryLoader extends PackageDirectoryLoader {
if (this.includeNodes.length) { if (this.includeNodes.length) {
const allowedNodes: typeof this.known.nodes = {}; const allowedNodes: typeof this.known.nodes = {};
for (const nodeName of this.includeNodes) { for (const fullNodeType of this.includeNodes) {
if (nodeName in this.known.nodes) { const [packageName, nodeType] = fullNodeType.split('.');
allowedNodes[nodeName] = this.known.nodes[nodeName]; if (packageName === this.packageName && nodeType in this.known.nodes) {
allowedNodes[nodeType] = this.known.nodes[nodeType];
} }
} }
this.known.nodes = allowedNodes; this.known.nodes = allowedNodes;
@@ -439,8 +514,11 @@ export class LazyPackageDirectoryLoader extends PackageDirectoryLoader {
} }
if (this.excludeNodes.length) { if (this.excludeNodes.length) {
for (const nodeName of this.excludeNodes) { for (const fullNodeType of this.excludeNodes) {
delete this.known.nodes[nodeName]; const [packageName, nodeType] = fullNodeType.split('.');
if (packageName === this.packageName) {
delete this.known.nodes[nodeType];
}
} }
this.types.nodes = this.types.nodes.filter( this.types.nodes = this.types.nodes.filter(

View File

@@ -3,3 +3,5 @@ export { DisallowedFilepathError } from './disallowed-filepath.error';
export { InvalidModeError } from './invalid-mode.error'; export { InvalidModeError } from './invalid-mode.error';
export { InvalidManagerError } from './invalid-manager.error'; export { InvalidManagerError } from './invalid-manager.error';
export { InvalidExecutionMetadataError } from './invalid-execution-metadata.error'; export { InvalidExecutionMetadataError } from './invalid-execution-metadata.error';
export { UnrecognizedCredentialTypeError } from './unrecognized-credential-type.error';
export { UnrecognizedNodeTypeError } from './unrecognized-node-type.error';

View File

@@ -0,0 +1,9 @@
import { ApplicationError } from 'n8n-workflow';
export class UnrecognizedCredentialTypeError extends ApplicationError {
severity = 'warning';
constructor(credentialType: string) {
super(`Unrecognized credential type: ${credentialType}`);
}
}

View File

@@ -3,7 +3,7 @@ import { ApplicationError } from 'n8n-workflow';
export class UnrecognizedNodeTypeError extends ApplicationError { export class UnrecognizedNodeTypeError extends ApplicationError {
severity = 'warning'; severity = 'warning';
constructor(nodeType: string) { constructor(packageName: string, nodeType: string) {
super(`Unrecognized node type: ${nodeType}".`); super(`Unrecognized node type: ${packageName}.${nodeType}`);
} }
} }

View File

@@ -5,7 +5,6 @@ export * from './ActiveWorkflows';
export * from './BinaryData/BinaryData.service'; export * from './BinaryData/BinaryData.service';
export * from './BinaryData/types'; export * from './BinaryData/types';
export { Cipher } from './Cipher'; export { Cipher } from './Cipher';
export * from './ClassLoader';
export * from './Constants'; export * from './Constants';
export * from './Credentials'; export * from './Credentials';
export * from './DirectoryLoader'; export * from './DirectoryLoader';

View File

@@ -0,0 +1,52 @@
import vm from 'vm';
import { loadClassInIsolation } from '@/ClassLoader';
describe('ClassLoader', () => {
const filePath = '/path/to/TestClass.js';
const className = 'TestClass';
class TestClass {
getValue(): string {
return 'test value';
}
}
jest.spyOn(vm, 'createContext').mockReturnValue({});
const runInContext = jest.fn().mockImplementation(() => new TestClass());
const scriptSpy = jest.spyOn(vm, 'Script').mockImplementation(function (this: vm.Script) {
this.runInContext = runInContext;
return this;
});
beforeEach(() => {
jest.clearAllMocks();
});
it('should create script with correct require statement', () => {
const instance = loadClassInIsolation<TestClass>(filePath, className);
expect(scriptSpy).toHaveBeenCalledWith(`new (require('${filePath}').${className})()`);
expect(instance.getValue()).toBe('test value');
});
it('should handle Windows-style paths', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });
loadClassInIsolation('/path\\to\\TestClass.js', 'TestClass');
expect(scriptSpy).toHaveBeenCalledWith(`new (require('${filePath}').${className})()`);
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should throw error when script execution fails', () => {
runInContext.mockImplementationOnce(() => {
throw new Error('Script execution failed');
});
expect(() => loadClassInIsolation(filePath, className)).toThrow('Script execution failed');
});
});

View File

@@ -0,0 +1,781 @@
import { mock } from 'jest-mock-extended';
import type {
ICredentialType,
INodeType,
INodeTypeDescription,
IVersionedNodeType,
} from 'n8n-workflow';
import { deepCopy } from 'n8n-workflow';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
jest.mock('node:fs');
jest.mock('node:fs/promises');
const mockFs = mock<typeof fs>();
const mockFsPromises = mock<typeof fsPromises>();
fs.readFileSync = mockFs.readFileSync;
fsPromises.readFile = mockFsPromises.readFile;
jest.mock('fast-glob', () => async (pattern: string) => {
return pattern.endsWith('.node.js')
? ['dist/Node1/Node1.node.js', 'dist/Node2/Node2.node.js']
: ['dist/Credential1.js'];
});
import * as classLoader from '@/ClassLoader';
import {
CustomDirectoryLoader,
PackageDirectoryLoader,
LazyPackageDirectoryLoader,
} from '@/DirectoryLoader';
describe('DirectoryLoader', () => {
const directory = '/not/a/real/path';
const packageJson = JSON.stringify({
name: 'n8n-nodes-testing',
n8n: {
credentials: ['dist/Credential1.js'],
nodes: ['dist/Node1/Node1.node.js', 'dist/Node2/Node2.node.js'],
},
});
const createNode = (name: string, credential?: string) =>
mock<INodeType>({
description: {
name,
version: 1,
icon: `file:${name}.svg`,
iconUrl: undefined,
credentials: credential ? [{ name: credential }] : [],
properties: [],
},
});
const createCredential = (name: string) =>
mock<ICredentialType>({
name,
icon: `file:${name}.svg`,
iconUrl: undefined,
extends: undefined,
properties: [],
});
let mockCredential1: ICredentialType, mockNode1: INodeType, mockNode2: INodeType;
beforeEach(() => {
mockCredential1 = createCredential('credential1');
mockNode1 = createNode('node1', 'credential1');
mockNode2 = createNode('node2');
jest.clearAllMocks();
});
//@ts-expect-error overwrite a readonly property
classLoader.loadClassInIsolation = jest.fn((_: string, className: string) => {
if (className === 'Node1') return mockNode1;
if (className === 'Node2') return mockNode2;
if (className === 'Credential1') return mockCredential1;
throw new Error(`${className} is invalid`);
});
describe('CustomDirectoryLoader', () => {
it('should load custom nodes and credentials', async () => {
const loader = new CustomDirectoryLoader(directory);
expect(loader.packageName).toEqual('CUSTOM');
await loader.loadAll();
expect(loader.isLazyLoaded).toBe(false);
expect(mockFsPromises.readFile).not.toHaveBeenCalled();
expect(classLoader.loadClassInIsolation).toHaveBeenCalledTimes(3);
expect(loader.nodesByCredential).toEqual({ credential1: ['node1'] });
expect(loader.credentialTypes).toEqual({
credential1: { sourcePath: 'dist/Credential1.js', type: mockCredential1 },
});
expect(loader.nodeTypes).toEqual({
node1: { sourcePath: 'dist/Node1/Node1.node.js', type: mockNode1 },
node2: { sourcePath: 'dist/Node2/Node2.node.js', type: mockNode2 },
});
expect(mockCredential1.iconUrl).toBe('icons/CUSTOM/dist/credential1.svg');
expect(mockNode1.description.iconUrl).toBe('icons/CUSTOM/dist/Node1/node1.svg');
expect(mockNode2.description.iconUrl).toBe('icons/CUSTOM/dist/Node2/node2.svg');
expect(mockFs.readFileSync).not.toHaveBeenCalled();
});
});
describe('PackageDirectoryLoader', () => {
it('should load nodes and credentials from an installed package', async () => {
mockFs.readFileSync.calledWith(`${directory}/package.json`).mockReturnValue(packageJson);
const loader = new PackageDirectoryLoader(directory);
expect(loader.packageName).toEqual('n8n-nodes-testing');
await loader.loadAll();
expect(loader.isLazyLoaded).toBe(false);
expect(mockFsPromises.readFile).not.toHaveBeenCalled();
expect(classLoader.loadClassInIsolation).toHaveBeenCalledTimes(3);
expect(loader.nodesByCredential).toEqual({ credential1: ['node1'] });
expect(loader.credentialTypes).toEqual({
credential1: { sourcePath: 'dist/Credential1.js', type: mockCredential1 },
});
expect(loader.nodeTypes).toEqual({
node1: { sourcePath: 'dist/Node1/Node1.node.js', type: mockNode1 },
node2: { sourcePath: 'dist/Node2/Node2.node.js', type: mockNode2 },
});
expect(mockCredential1.iconUrl).toBe('icons/n8n-nodes-testing/dist/credential1.svg');
expect(mockNode1.description.iconUrl).toBe('icons/n8n-nodes-testing/dist/Node1/node1.svg');
expect(mockNode2.description.iconUrl).toBe('icons/n8n-nodes-testing/dist/Node2/node2.svg');
});
it('should throw error when package.json is missing', async () => {
mockFs.readFileSync.mockImplementationOnce(() => {
throw new Error('ENOENT');
});
expect(() => new PackageDirectoryLoader(directory)).toThrow();
});
it('should throw error when package.json is invalid', async () => {
mockFs.readFileSync.calledWith(`${directory}/package.json`).mockReturnValue('invalid json');
expect(() => new PackageDirectoryLoader(directory)).toThrow('Failed to parse JSON');
});
it('should do nothing if package.json has no n8n field', async () => {
mockFs.readFileSync.calledWith(`${directory}/package.json`).mockReturnValue(
JSON.stringify({
name: 'n8n-nodes-testing',
}),
);
const loader = new PackageDirectoryLoader(directory);
await loader.loadAll();
expect(loader.nodeTypes).toEqual({});
expect(loader.credentialTypes).toEqual({});
expect(classLoader.loadClassInIsolation).not.toHaveBeenCalled();
});
it('should hide httpRequestNode property when credential has supported nodes', async () => {
mockFs.readFileSync.calledWith(`${directory}/package.json`).mockReturnValue(packageJson);
mockCredential1.httpRequestNode = mock<ICredentialType['httpRequestNode']>({ hidden: false });
const loader = new PackageDirectoryLoader(directory);
await loader.loadAll();
expect(mockCredential1.httpRequestNode?.hidden).toBe(true);
});
it('should not modify httpRequestNode when credential has no supported nodes', async () => {
mockFs.readFileSync.calledWith(`${directory}/package.json`).mockReturnValue(packageJson);
mockCredential1.httpRequestNode = mock<ICredentialType['httpRequestNode']>({ hidden: false });
mockNode1.description.credentials = [];
const loader = new PackageDirectoryLoader(directory);
await loader.loadAll();
expect(mockCredential1.httpRequestNode?.hidden).toBe(false);
});
it('should inherit iconUrl from supported node when credential has no icon', async () => {
mockFs.readFileSync.calledWith(`${directory}/package.json`).mockReturnValue(packageJson);
mockCredential1.icon = undefined;
const loader = new PackageDirectoryLoader(directory);
await loader.loadAll();
expect(mockCredential1.supportedNodes).toEqual(['node1']);
expect(mockCredential1.iconUrl).toBe(mockNode1.description.iconUrl);
});
});
describe('LazyPackageDirectoryLoader', () => {
it('should skip loading nodes and credentials from a lazy-loadable package', async () => {
mockFs.readFileSync.calledWith(`${directory}/package.json`).mockReturnValue(packageJson);
mockFsPromises.readFile.mockResolvedValue('[]');
const loader = new LazyPackageDirectoryLoader(directory);
expect(loader.packageName).toEqual('n8n-nodes-testing');
await loader.loadAll();
expect(loader.isLazyLoaded).toBe(true);
expect(mockFsPromises.readFile).toHaveBeenCalledTimes(4);
expect(classLoader.loadClassInIsolation).not.toHaveBeenCalled();
});
it('should fall back to non-lazy loading if any json file fails to parse', async () => {
mockFs.readFileSync.calledWith(`${directory}/package.json`).mockReturnValue(packageJson);
mockFsPromises.readFile.mockRejectedValue(new Error('Failed to read file'));
const loader = new LazyPackageDirectoryLoader(directory);
await loader.loadAll();
expect(loader.isLazyLoaded).toBe(false);
expect(mockFsPromises.readFile).toHaveBeenCalled();
expect(classLoader.loadClassInIsolation).toHaveBeenCalledTimes(3);
});
it('should only load included nodes when includeNodes is set', async () => {
mockFs.readFileSync.calledWith(`${directory}/package.json`).mockReturnValue(packageJson);
mockFsPromises.readFile.mockImplementation(async (path) => {
if (typeof path !== 'string') throw new Error('Invalid path');
if (path.endsWith('known/nodes.json')) {
return JSON.stringify({
node1: { className: 'Node1', sourcePath: 'dist/Node1/Node1.node.js' },
node2: { className: 'Node2', sourcePath: 'dist/Node2/Node2.node.js' },
});
}
if (path.endsWith('known/credentials.json')) {
return JSON.stringify({});
}
if (path.endsWith('types/nodes.json')) {
return JSON.stringify([
{ name: 'n8n-nodes-testing.node1' },
{ name: 'n8n-nodes-testing.node2' },
]);
}
if (path.endsWith('types/credentials.json')) {
return JSON.stringify([]);
}
throw new Error('File not found');
});
const loader = new LazyPackageDirectoryLoader(directory, [], ['n8n-nodes-testing.node1']);
await loader.loadAll();
expect(loader.isLazyLoaded).toBe(true);
expect(loader.known.nodes).toEqual({
node1: { className: 'Node1', sourcePath: 'dist/Node1/Node1.node.js' },
});
expect(loader.types.nodes).toHaveLength(1);
expect(loader.types.nodes[0].name).toBe('n8n-nodes-testing.node1');
expect(classLoader.loadClassInIsolation).not.toHaveBeenCalled();
});
it('should load no nodes when includeNodes does not match any nodes', async () => {
mockFs.readFileSync.calledWith(`${directory}/package.json`).mockReturnValue(packageJson);
mockFsPromises.readFile.mockImplementation(async (path) => {
if (typeof path !== 'string') throw new Error('Invalid path');
if (path.endsWith('known/nodes.json')) {
return JSON.stringify({
node1: { className: 'Node1', sourcePath: 'dist/Node1/Node1.node.js' },
node2: { className: 'Node2', sourcePath: 'dist/Node2/Node2.node.js' },
});
}
if (path.endsWith('known/credentials.json')) {
return JSON.stringify({});
}
if (path.endsWith('types/nodes.json')) {
return JSON.stringify([
{ name: 'n8n-nodes-testing.node1' },
{ name: 'n8n-nodes-testing.node2' },
]);
}
if (path.endsWith('types/credentials.json')) {
return JSON.stringify([]);
}
throw new Error('File not found');
});
const loader = new LazyPackageDirectoryLoader(
directory,
[],
['n8n-nodes-testing.nonexistent'],
);
await loader.loadAll();
expect(loader.isLazyLoaded).toBe(true);
expect(loader.known.nodes).toEqual({});
expect(loader.types.nodes).toHaveLength(0);
expect(classLoader.loadClassInIsolation).not.toHaveBeenCalled();
});
it('should exclude specified nodes when excludeNodes is set', async () => {
mockFs.readFileSync.calledWith(`${directory}/package.json`).mockReturnValue(packageJson);
mockFsPromises.readFile.mockImplementation(async (path) => {
if (typeof path !== 'string') throw new Error('Invalid path');
if (path.endsWith('known/nodes.json')) {
return JSON.stringify({
node1: { className: 'Node1', sourcePath: 'dist/Node1/Node1.node.js' },
node2: { className: 'Node2', sourcePath: 'dist/Node2/Node2.node.js' },
});
}
if (path.endsWith('known/credentials.json')) {
return JSON.stringify({});
}
if (path.endsWith('types/nodes.json')) {
return JSON.stringify([
{ name: 'n8n-nodes-testing.node1' },
{ name: 'n8n-nodes-testing.node2' },
]);
}
if (path.endsWith('types/credentials.json')) {
return JSON.stringify([]);
}
throw new Error('File not found');
});
const loader = new LazyPackageDirectoryLoader(directory, ['n8n-nodes-testing.node1']);
await loader.loadAll();
expect(loader.isLazyLoaded).toBe(true);
expect(loader.known.nodes).toEqual({
node2: { className: 'Node2', sourcePath: 'dist/Node2/Node2.node.js' },
});
expect(loader.types.nodes).toHaveLength(1);
expect(loader.types.nodes[0].name).toBe('n8n-nodes-testing.node2');
expect(classLoader.loadClassInIsolation).not.toHaveBeenCalled();
});
});
describe('reset()', () => {
it('should reset all properties to their initial state', async () => {
mockFs.readFileSync.calledWith(`${directory}/package.json`).mockReturnValue(packageJson);
const loader = new PackageDirectoryLoader(directory);
await loader.loadAll();
// Verify loader has loaded data
expect(loader.nodeTypes).not.toEqual({});
expect(loader.credentialTypes).not.toEqual({});
expect(loader.types.nodes.length).toBeGreaterThan(0);
expect(loader.types.credentials.length).toBeGreaterThan(0);
expect(loader.loadedNodes.length).toBeGreaterThan(0);
expect(Object.keys(loader.known.nodes).length).toBeGreaterThan(0);
expect(Object.keys(loader.known.credentials).length).toBeGreaterThan(0);
// Reset the loader
loader.reset();
// Verify all properties are reset
expect(loader.nodeTypes).toEqual({});
expect(loader.credentialTypes).toEqual({});
expect(loader.types.nodes).toEqual([]);
expect(loader.types.credentials).toEqual([]);
expect(loader.loadedNodes).toEqual([]);
expect(loader.known.nodes).toEqual({});
expect(loader.known.credentials).toEqual({});
});
});
describe('getVersionedNodeTypeAll', () => {
it('should return array with single node for non-versioned node', () => {
const loader = new CustomDirectoryLoader(directory);
const node = createNode('node1');
const result = loader.getVersionedNodeTypeAll(node);
expect(result).toHaveLength(1);
expect(result[0]).toBe(node);
});
it('should return all versions of a versioned node', () => {
const loader = new CustomDirectoryLoader(directory);
const nodeV1 = createNode('test');
const nodeV2 = createNode('test');
nodeV1.description.version = 1;
nodeV2.description.version = 2;
const versionedNode = mock<IVersionedNodeType>({
description: { name: 'test', codex: {} },
currentVersion: 2,
nodeVersions: {
1: nodeV1,
2: nodeV2,
},
});
const result = loader.getVersionedNodeTypeAll(versionedNode);
expect(result).toHaveLength(2);
expect(result).toEqual([nodeV2, nodeV1]);
expect(result[0].description.name).toBe('test');
expect(result[1].description.name).toBe('test');
});
});
describe('getCredentialsForNode', () => {
it('should return empty array if node has no credentials', () => {
const loader = new CustomDirectoryLoader(directory);
const node = createNode('node1');
const result = loader.getCredentialsForNode(node);
expect(Array.isArray(result)).toBe(true);
expect(result.length).toEqual(0);
});
it('should return credentials for non-versioned node', () => {
const loader = new CustomDirectoryLoader(directory);
const node = createNode('node1', 'testCred');
const result = loader.getCredentialsForNode(node);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('testCred');
});
it('should return unique credentials from all versions of a versioned node', () => {
const loader = new CustomDirectoryLoader(directory);
const nodeV1 = createNode('test', 'cred1');
const nodeV2 = createNode('test', 'cred2');
const versionedNode = mock<IVersionedNodeType>({
description: { name: 'test' },
currentVersion: 2,
nodeVersions: {
1: nodeV1,
2: nodeV2,
},
});
const result = loader.getCredentialsForNode(versionedNode);
expect(result).toHaveLength(2);
expect(result[0].name).toBe('cred1');
expect(result[1].name).toBe('cred2');
});
it('should remove duplicate credentials from different versions', () => {
const loader = new CustomDirectoryLoader(directory);
const nodeV1 = createNode('test', 'cred1');
const nodeV2 = createNode('test', 'cred1'); // Same credential
const versionedNode = mock<IVersionedNodeType>({
description: { name: 'test' },
currentVersion: 2,
nodeVersions: {
1: nodeV1,
2: nodeV2,
},
});
const result = loader.getCredentialsForNode(versionedNode);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('cred1');
});
});
describe('loadCredentialFromFile', () => {
it('should load credential and store it correctly', () => {
const loader = new CustomDirectoryLoader(directory);
const filePath = 'dist/Credential1.js';
loader.loadCredentialFromFile(filePath);
expect(loader.credentialTypes).toEqual({
credential1: {
type: mockCredential1,
sourcePath: filePath,
},
});
expect(loader.known.credentials).toEqual({
credential1: {
className: mockCredential1.constructor.name,
sourcePath: filePath,
extends: undefined,
supportedNodes: undefined,
},
});
expect(loader.types.credentials).toEqual([mockCredential1]);
});
it('should update credential icon paths', () => {
const loader = new CustomDirectoryLoader(directory);
const filePath = 'dist/Credential1.js';
const credWithIcon = createCredential('credentialWithIcon');
credWithIcon.icon = {
light: 'file:light.svg',
dark: 'file:dark.svg',
};
jest.spyOn(classLoader, 'loadClassInIsolation').mockReturnValueOnce(credWithIcon);
loader.loadCredentialFromFile(filePath);
expect(credWithIcon.iconUrl).toEqual({
light: 'icons/CUSTOM/dist/light.svg',
dark: 'icons/CUSTOM/dist/dark.svg',
});
expect(credWithIcon.icon).toBeUndefined();
});
it('should add toJSON method to credential type', () => {
const loader = new CustomDirectoryLoader(directory);
const filePath = 'dist/Credential1.js';
const credWithAuth = createCredential('credWithAuth');
credWithAuth.authenticate = jest.fn();
jest.spyOn(classLoader, 'loadClassInIsolation').mockReturnValueOnce(credWithAuth);
loader.loadCredentialFromFile(filePath);
const serialized = deepCopy(credWithAuth);
expect(serialized.authenticate).toEqual({});
});
it('should store credential extends and supported nodes info', () => {
const loader = new CustomDirectoryLoader(directory);
const filePath = 'dist/Credential1.js';
const extendingCred = createCredential('extendingCred');
extendingCred.extends = ['baseCredential'];
jest.spyOn(classLoader, 'loadClassInIsolation').mockReturnValueOnce(extendingCred);
// Set up nodesByCredential before loading
loader.nodesByCredential.extendingCred = ['node1', 'node2'];
loader.loadCredentialFromFile(filePath);
expect(loader.known.credentials.extendingCred).toEqual({
className: extendingCred.constructor.name,
sourcePath: filePath,
extends: ['baseCredential'],
supportedNodes: ['node1', 'node2'],
});
});
it('should throw error if credential class cannot be loaded', () => {
const loader = new CustomDirectoryLoader(directory);
const filePath = 'dist/InvalidCred.js';
jest.spyOn(classLoader, 'loadClassInIsolation').mockImplementationOnce(() => {
throw new TypeError('Class not found');
});
expect(() => loader.loadCredentialFromFile(filePath)).toThrow('Class could not be found');
});
});
describe('getCredential', () => {
it('should return existing loaded credential type', () => {
const loader = new CustomDirectoryLoader(directory);
const filePath = 'dist/Credential1.js';
loader.loadCredentialFromFile(filePath);
const result = loader.getCredential('credential1');
expect(result).toEqual({
type: mockCredential1,
sourcePath: filePath,
});
});
it('should load credential from known credentials if not already loaded', () => {
const loader = new CustomDirectoryLoader(directory);
const filePath = 'dist/Credential1.js';
// Setup known credentials without loading
loader.known.credentials.credential1 = {
className: 'Credential1',
sourcePath: filePath,
};
const result = loader.getCredential('credential1');
expect(result).toEqual({
type: mockCredential1,
sourcePath: filePath,
});
expect(classLoader.loadClassInIsolation).toHaveBeenCalledWith(
expect.stringContaining(filePath),
'Credential1',
);
});
it('should throw UnrecognizedCredentialTypeError if credential type is not found', () => {
const loader = new CustomDirectoryLoader(directory);
expect(() => loader.getCredential('nonexistent')).toThrow(
'Unrecognized credential type: nonexistent',
);
});
});
describe('loadNodeFromFile', () => {
it('should load node and store it correctly', () => {
const loader = new CustomDirectoryLoader(directory);
const filePath = 'dist/Node1/Node1.node.js';
loader.loadNodeFromFile(filePath);
expect(loader.nodeTypes).toEqual({
node1: {
type: mockNode1,
sourcePath: filePath,
},
});
expect(loader.known.nodes).toEqual({
node1: {
className: mockNode1.constructor.name,
sourcePath: filePath,
},
});
expect(loader.types.nodes).toEqual([mockNode1.description]);
expect(loader.loadedNodes).toEqual([{ name: 'node1', version: 1 }]);
});
it('should update node icon paths', () => {
const loader = new CustomDirectoryLoader(directory);
const filePath = 'dist/Node1/Node1.node.js';
const nodeWithIcon = createNode('nodeWithIcon');
nodeWithIcon.description.icon = {
light: 'file:light.svg',
dark: 'file:dark.svg',
};
jest.spyOn(classLoader, 'loadClassInIsolation').mockReturnValueOnce(nodeWithIcon);
loader.loadNodeFromFile(filePath);
expect(nodeWithIcon.description.iconUrl).toEqual({
light: 'icons/CUSTOM/dist/Node1/light.svg',
dark: 'icons/CUSTOM/dist/Node1/dark.svg',
});
expect(nodeWithIcon.description.icon).toBeUndefined();
});
it('should skip node if included in excludeNodes', () => {
const loader = new CustomDirectoryLoader(directory, ['CUSTOM.node1']);
const filePath = 'dist/Node1/Node1.node.js';
loader.loadNodeFromFile(filePath);
expect(loader.nodeTypes).toEqual({});
expect(loader.known.nodes).toEqual({});
expect(loader.types.nodes).toEqual([]);
expect(loader.loadedNodes).toEqual([]);
});
it('should skip node if not in includeNodes', () => {
const loader = new CustomDirectoryLoader(directory, [], ['CUSTOM.other']);
const filePath = 'dist/Node1/Node1.node.js';
loader.loadNodeFromFile(filePath);
expect(loader.nodeTypes).toEqual({});
expect(loader.known.nodes).toEqual({});
expect(loader.types.nodes).toEqual([]);
expect(loader.loadedNodes).toEqual([]);
});
it('should handle versioned nodes correctly', () => {
const loader = new CustomDirectoryLoader(directory);
const filePath = 'dist/Node1/Node1.node.js';
const nodeV1 = createNode('test');
const nodeV2 = createNode('test');
nodeV1.description.version = 1;
nodeV2.description.version = 2;
const versionedNode = mock<IVersionedNodeType>({
description: { name: 'test', codex: {}, iconUrl: undefined, icon: undefined },
currentVersion: 2,
nodeVersions: {
1: nodeV1,
2: nodeV2,
},
});
jest.spyOn(classLoader, 'loadClassInIsolation').mockReturnValueOnce(versionedNode);
loader.loadNodeFromFile(filePath);
expect(loader.loadedNodes).toEqual([{ name: 'test', version: 2 }]);
const nodes = loader.types.nodes as INodeTypeDescription[];
expect(nodes).toHaveLength(2);
expect(nodes[0]?.version).toBe(2);
expect(nodes[1]?.version).toBe(1);
});
it('should store credential associations correctly', () => {
const loader = new CustomDirectoryLoader(directory);
const filePath = 'dist/Node1/Node1.node.js';
const nodeWithCreds = createNode('testNode', 'testCred');
jest.spyOn(classLoader, 'loadClassInIsolation').mockReturnValueOnce(nodeWithCreds);
loader.loadNodeFromFile(filePath);
expect(loader.nodesByCredential).toEqual({
testCred: ['testNode'],
});
});
it('should throw error if node class cannot be loaded', () => {
const loader = new CustomDirectoryLoader(directory);
const filePath = 'dist/InvalidNode/InvalidNode.node.js';
jest.spyOn(classLoader, 'loadClassInIsolation').mockImplementationOnce(() => {
throw new TypeError('Class not found');
});
expect(() => loader.loadNodeFromFile(filePath)).toThrow('Class could not be found');
});
});
describe('getNode', () => {
it('should return existing loaded node type', () => {
const loader = new CustomDirectoryLoader(directory);
const filePath = 'dist/Node1/Node1.node.js';
loader.loadNodeFromFile(filePath);
const result = loader.getNode('node1');
expect(result).toEqual({
type: mockNode1,
sourcePath: filePath,
});
});
it('should load node from known nodes if not already loaded', () => {
const loader = new CustomDirectoryLoader(directory);
const filePath = 'dist/Node1/Node1.node.js';
// Setup known nodes without loading
loader.known.nodes.node1 = {
className: 'Node1',
sourcePath: filePath,
};
const result = loader.getNode('node1');
expect(result).toEqual({
type: mockNode1,
sourcePath: filePath,
});
expect(classLoader.loadClassInIsolation).toHaveBeenCalledWith(
expect.stringContaining(filePath),
'Node1',
);
});
it('should throw UnrecognizedNodeTypeError if node type is not found', () => {
const loader = new CustomDirectoryLoader(directory);
expect(() => loader.getNode('nonexistent')).toThrow(
'Unrecognized node type: CUSTOM.nonexistent',
);
});
});
});

View File

@@ -17,6 +17,8 @@ import type {
import { ApplicationError, NodeHelpers, WorkflowHooks } from 'n8n-workflow'; import { ApplicationError, NodeHelpers, WorkflowHooks } from 'n8n-workflow';
import path from 'path'; import path from 'path';
import { UnrecognizedNodeTypeError } from '@/errors';
import { predefinedNodesTypes } from './constants'; import { predefinedNodesTypes } from './constants';
const BASE_DIR = path.resolve(__dirname, '../../..'); const BASE_DIR = path.resolve(__dirname, '../../..');
@@ -102,12 +104,9 @@ export function getNodeTypes(testData: WorkflowTestData[] | WorkflowTestData) {
); );
for (const nodeName of nodeNames) { for (const nodeName of nodeNames) {
if (!nodeName.startsWith('n8n-nodes-base.')) {
throw new ApplicationError('Unknown node type', { tags: { nodeType: nodeName } });
}
const loadInfo = knownNodes[nodeName.replace('n8n-nodes-base.', '')]; const loadInfo = knownNodes[nodeName.replace('n8n-nodes-base.', '')];
if (!loadInfo) { if (!loadInfo) {
throw new ApplicationError('Unknown node type', { tags: { nodeType: nodeName } }); throw new UnrecognizedNodeTypeError('n8n-nodes-base', nodeName);
} }
const sourcePath = loadInfo.sourcePath.replace(/^dist\//, './').replace(/\.js$/, '.ts'); const sourcePath = loadInfo.sourcePath.replace(/^dist\//, './').replace(/\.js$/, '.ts');
const nodeSourcePath = path.join(BASE_DIR, 'nodes-base', sourcePath); const nodeSourcePath = path.join(BASE_DIR, 'nodes-base', sourcePath);

View File

@@ -7,13 +7,12 @@
"clean": "rimraf dist .turbo", "clean": "rimraf dist .turbo",
"dev": "pnpm watch", "dev": "pnpm watch",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && pnpm n8n-copy-icons && pnpm n8n-generate-translations && pnpm build:metadata", "build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && pnpm n8n-copy-icons && pnpm n8n-generate-translations && pnpm n8n-generate-metadata",
"build:metadata": "pnpm n8n-generate-known && pnpm n8n-generate-ui-types",
"format": "biome format --write .", "format": "biome format --write .",
"format:check": "biome ci .", "format:check": "biome ci .",
"lint": "eslint . --quiet && node ./scripts/validate-load-options-methods.js", "lint": "eslint . --quiet && node ./scripts/validate-load-options-methods.js",
"lintfix": "eslint . --fix", "lintfix": "eslint . --fix",
"watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\" --onSuccess \"pnpm n8n-generate-ui-types\"", "watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\" --onSuccess \"pnpm n8n-generate-metadata\"",
"test": "jest" "test": "jest"
}, },
"files": [ "files": [

View File

@@ -6,7 +6,7 @@ try {
definedMethods = require('../dist/methods/defined.json'); definedMethods = require('../dist/methods/defined.json');
} catch (error) { } catch (error) {
console.error( console.error(
'Failed to find methods to validate. Please run `npm run n8n-generate-ui-types` first.', 'Failed to find methods to validate. Please run `npm run n8n-generate-metadata` first.',
); );
process.exit(1); process.exit(1);
} }

View File

@@ -4,7 +4,12 @@ import { tmpdir } from 'os';
import nock from 'nock'; import nock from 'nock';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { get } from 'lodash'; import { get } from 'lodash';
import { BinaryDataService, Credentials, constructExecutionMetaData } from 'n8n-core'; import {
BinaryDataService,
Credentials,
UnrecognizedNodeTypeError,
constructExecutionMetaData,
} from 'n8n-core';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { import type {
@@ -251,12 +256,9 @@ export function setup(testData: WorkflowTestData[] | WorkflowTestData) {
const nodeNames = nodes.map((n) => n.type); const nodeNames = nodes.map((n) => n.type);
for (const nodeName of nodeNames) { for (const nodeName of nodeNames) {
if (!nodeName.startsWith('n8n-nodes-base.')) {
throw new ApplicationError(`Unknown node type: ${nodeName}`, { level: 'warning' });
}
const loadInfo = knownNodes[nodeName.replace('n8n-nodes-base.', '')]; const loadInfo = knownNodes[nodeName.replace('n8n-nodes-base.', '')];
if (!loadInfo) { if (!loadInfo) {
throw new ApplicationError(`Unknown node type: ${nodeName}`, { level: 'warning' }); throw new UnrecognizedNodeTypeError('n8n-nodes-base', nodeName);
} }
const sourcePath = loadInfo.sourcePath.replace(/^dist\//, './').replace(/\.js$/, '.ts'); const sourcePath = loadInfo.sourcePath.replace(/^dist\//, './').replace(/\.js$/, '.ts');
const nodeSourcePath = path.join(baseDir, sourcePath); const nodeSourcePath = path.join(baseDir, sourcePath);

View File

@@ -2015,7 +2015,6 @@ export interface IWebhookDescription {
responseData?: WebhookResponseData | string; responseData?: WebhookResponseData | string;
restartWebhook?: boolean; restartWebhook?: boolean;
isForm?: boolean; isForm?: boolean;
hasLifecycleMethods?: boolean; // set automatically by generate-ui-types
ndvHideUrl?: string | boolean; // If true the webhook will not be displayed in the editor ndvHideUrl?: string | boolean; // If true the webhook will not be displayed in the editor
ndvHideMethod?: string | boolean; // If true the method will not be displayed in the editor ndvHideMethod?: string | boolean; // If true the method will not be displayed in the editor
} }

View File

@@ -1,14 +1,10 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-use-before-define */ /* eslint-disable @typescript-eslint/no-use-before-define */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
/* eslint-disable prefer-spread */ /* eslint-disable prefer-spread */
import get from 'lodash/get'; import get from 'lodash/get';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import uniqBy from 'lodash/uniqBy';
import { SINGLE_EXECUTION_NODES } from './Constants'; import { SINGLE_EXECUTION_NODES } from './Constants';
import { ApplicationError } from './errors/application.error'; import { ApplicationError } from './errors/application.error';
@@ -1972,40 +1968,6 @@ export function getVersionedNodeType(
return object; return object;
} }
export function getVersionedNodeTypeAll(object: IVersionedNodeType | INodeType): INodeType[] {
if ('nodeVersions' in object) {
return uniqBy(
Object.values(object.nodeVersions)
.map((element) => {
element.description.name = object.description.name;
element.description.codex = object.description.codex;
return element;
})
.reverse(),
(node) => {
const { version } = node.description;
return Array.isArray(version) ? version.join(',') : version.toString();
},
);
}
return [object];
}
export function getCredentialsForNode(
object: IVersionedNodeType | INodeType,
): INodeCredentialDescription[] {
if ('nodeVersions' in object) {
return uniqBy(
Object.values(object.nodeVersions).flatMap(
(version) => version.description.credentials ?? [],
),
'name',
);
}
return object.description.credentials ?? [];
}
export function isSingleExecution(type: string, parameters: INodeParameters): boolean { export function isSingleExecution(type: string, parameters: INodeParameters): boolean {
const singleExecutionCase = SINGLE_EXECUTION_NODES[type]; const singleExecutionCase = SINGLE_EXECUTION_NODES[type];