node-loader.test.tsโข24.5 kB
import { describe, it, expect, vi, beforeEach, afterEach, MockInstance } from 'vitest';
// Mock path module
vi.mock('path', async () => {
const actual = await vi.importActual<typeof import('path')>('path');
return {
...actual,
default: actual
};
});
describe('N8nNodeLoader', () => {
let N8nNodeLoader: any;
let consoleLogSpy: MockInstance;
let consoleErrorSpy: MockInstance;
let consoleWarnSpy: MockInstance;
// Create mocks for require and require.resolve
const mockRequire = vi.fn();
const mockRequireResolve = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
// Mock console methods
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// Reset mocks
mockRequire.mockReset();
mockRequireResolve.mockReset();
(mockRequire as any).resolve = mockRequireResolve;
// Default implementation for require.resolve
mockRequireResolve.mockImplementation((path: string) => path);
});
afterEach(() => {
// Restore console methods
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
consoleWarnSpy.mockRestore();
});
// Helper to create a loader instance with mocked require
async function createLoaderWithMocks() {
// Intercept the module and replace require
vi.doMock('@/loaders/node-loader', () => {
const originalModule = vi.importActual('@/loaders/node-loader');
return {
...originalModule,
N8nNodeLoader: class MockedN8nNodeLoader {
private readonly CORE_PACKAGES = [
{ name: 'n8n-nodes-base', path: 'n8n-nodes-base' },
{ name: '@n8n/n8n-nodes-langchain', path: '@n8n/n8n-nodes-langchain' }
];
async loadAllNodes() {
const results: any[] = [];
for (const pkg of this.CORE_PACKAGES) {
try {
console.log(`๐ฆ Loading package: ${pkg.name} from ${pkg.path}`);
const packageJson = mockRequire(`${pkg.path}/package.json`);
console.log(` Found ${Object.keys(packageJson.n8n?.nodes || {}).length} nodes in package.json`);
const nodes = await this.loadPackageNodes(pkg.name, pkg.path, packageJson);
results.push(...nodes);
} catch (error) {
console.error(`Failed to load ${pkg.name}:`, error);
}
}
return results;
}
private async loadPackageNodes(packageName: string, packagePath: string, packageJson: any) {
const n8nConfig = packageJson.n8n || {};
const nodes: any[] = [];
const nodesList = n8nConfig.nodes || [];
if (Array.isArray(nodesList)) {
for (const nodePath of nodesList) {
try {
const fullPath = mockRequireResolve(`${packagePath}/${nodePath}`);
const nodeModule = mockRequire(fullPath);
const nodeNameMatch = nodePath.match(/\/([^\/]+)\.node\.(js|ts)$/);
const nodeName = nodeNameMatch ? nodeNameMatch[1] : nodePath.replace(/.*\//, '').replace(/\.node\.(js|ts)$/, '');
const NodeClass = nodeModule.default || nodeModule[nodeName] || Object.values(nodeModule)[0];
if (NodeClass) {
nodes.push({ packageName, nodeName, NodeClass });
console.log(` โ Loaded ${nodeName} from ${packageName}`);
} else {
console.warn(` โ No valid export found for ${nodeName} in ${packageName}`);
}
} catch (error) {
console.error(` โ Failed to load node from ${packageName}/${nodePath}:`, (error as Error).message);
}
}
} else {
for (const [nodeName, nodePath] of Object.entries(nodesList)) {
try {
const fullPath = mockRequireResolve(`${packagePath}/${nodePath as string}`);
const nodeModule = mockRequire(fullPath);
const NodeClass = nodeModule.default || nodeModule[nodeName] || Object.values(nodeModule)[0];
if (NodeClass) {
nodes.push({ packageName, nodeName, NodeClass });
console.log(` โ Loaded ${nodeName} from ${packageName}`);
} else {
console.warn(` โ No valid export found for ${nodeName} in ${packageName}`);
}
} catch (error) {
console.error(` โ Failed to load node ${nodeName} from ${packageName}:`, (error as Error).message);
}
}
}
return nodes;
}
}
};
});
const module = await import('@/loaders/node-loader');
return new module.N8nNodeLoader();
}
describe('loadAllNodes', () => {
it('should load nodes from all configured packages', async () => {
// Mock package.json for n8n-nodes-base (array format)
const basePackageJson = {
n8n: {
nodes: [
'dist/nodes/Slack/Slack.node.js',
'dist/nodes/HTTP/HTTP.node.js'
]
}
};
// Mock package.json for langchain (object format)
const langchainPackageJson = {
n8n: {
nodes: {
'OpenAI': 'dist/nodes/OpenAI/OpenAI.node.js',
'Pinecone': 'dist/nodes/Pinecone/Pinecone.node.js'
}
}
};
// Mock node classes
class SlackNode { name = 'Slack'; }
class HTTPNode { name = 'HTTP'; }
class OpenAINode { name = 'OpenAI'; }
class PineconeNode { name = 'Pinecone'; }
// Setup require mocks
mockRequire.mockImplementation((path: string) => {
if (path === 'n8n-nodes-base/package.json') return basePackageJson;
if (path === '@n8n/n8n-nodes-langchain/package.json') return langchainPackageJson;
if (path.includes('Slack.node.js')) return { default: SlackNode };
if (path.includes('HTTP.node.js')) return { default: HTTPNode };
if (path.includes('OpenAI.node.js')) return { default: OpenAINode };
if (path.includes('Pinecone.node.js')) return { default: PineconeNode };
throw new Error(`Module not found: ${path}`);
});
const loader = await createLoaderWithMocks();
const results = await loader.loadAllNodes();
expect(results).toHaveLength(4);
expect(results).toContainEqual({
packageName: 'n8n-nodes-base',
nodeName: 'Slack',
NodeClass: SlackNode
});
expect(results).toContainEqual({
packageName: 'n8n-nodes-base',
nodeName: 'HTTP',
NodeClass: HTTPNode
});
expect(results).toContainEqual({
packageName: '@n8n/n8n-nodes-langchain',
nodeName: 'OpenAI',
NodeClass: OpenAINode
});
expect(results).toContainEqual({
packageName: '@n8n/n8n-nodes-langchain',
nodeName: 'Pinecone',
NodeClass: PineconeNode
});
// Verify console logs
expect(consoleLogSpy).toHaveBeenCalledWith('๐ฆ Loading package: n8n-nodes-base from n8n-nodes-base');
expect(consoleLogSpy).toHaveBeenCalledWith(' Found 2 nodes in package.json');
expect(consoleLogSpy).toHaveBeenCalledWith(' โ Loaded Slack from n8n-nodes-base');
expect(consoleLogSpy).toHaveBeenCalledWith(' โ Loaded HTTP from n8n-nodes-base');
});
it('should handle missing packages gracefully', async () => {
mockRequire.mockImplementation((path: string) => {
throw new Error(`Cannot find module '${path}'`);
});
const loader = await createLoaderWithMocks();
const results = await loader.loadAllNodes();
expect(results).toHaveLength(0);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to load n8n-nodes-base:',
expect.any(Error)
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to load @n8n/n8n-nodes-langchain:',
expect.any(Error)
);
});
it('should handle packages with no n8n config', async () => {
const emptyPackageJson = {};
mockRequire.mockImplementation((path: string) => {
if (path.includes('package.json')) return emptyPackageJson;
throw new Error(`Module not found: ${path}`);
});
const loader = await createLoaderWithMocks();
const results = await loader.loadAllNodes();
expect(results).toHaveLength(0);
expect(consoleLogSpy).toHaveBeenCalledWith(' Found 0 nodes in package.json');
});
});
describe('loadPackageNodes - array format', () => {
it('should load nodes with default export', async () => {
const packageJson = {
n8n: {
nodes: ['dist/nodes/Test/Test.node.js']
}
};
class TestNode { name = 'Test'; }
mockRequire.mockImplementation((path: string) => {
if (path.includes('Test.node.js')) return { default: TestNode };
return packageJson;
});
const loader = await createLoaderWithMocks();
const results = await loader['loadPackageNodes']('test-package', 'test-package', packageJson);
expect(results).toHaveLength(1);
expect(results[0]).toEqual({
packageName: 'test-package',
nodeName: 'Test',
NodeClass: TestNode
});
});
it('should load nodes with named export matching node name', async () => {
const packageJson = {
n8n: {
nodes: ['dist/nodes/Custom/Custom.node.js']
}
};
class CustomNode { name = 'Custom'; }
mockRequire.mockImplementation((path: string) => {
if (path.includes('Custom.node.js')) return { Custom: CustomNode };
return packageJson;
});
const loader = await createLoaderWithMocks();
const results = await loader['loadPackageNodes']('test-package', 'test-package', packageJson);
expect(results).toHaveLength(1);
expect(results[0].NodeClass).toBe(CustomNode);
});
it('should load nodes with object values export', async () => {
const packageJson = {
n8n: {
nodes: ['dist/nodes/Widget/Widget.node.js']
}
};
class WidgetNode { name = 'Widget'; }
mockRequire.mockImplementation((path: string) => {
if (path.includes('Widget.node.js')) return { SomeExport: WidgetNode };
return packageJson;
});
const loader = await createLoaderWithMocks();
const results = await loader['loadPackageNodes']('test-package', 'test-package', packageJson);
expect(results).toHaveLength(1);
expect(results[0].NodeClass).toBe(WidgetNode);
});
it('should extract node name from complex paths', async () => {
const packageJson = {
n8n: {
nodes: [
'dist/nodes/Complex/Path/ComplexNode.node.js',
'dist/nodes/Another.node.ts',
'some/weird/path/NoExtension'
]
}
};
class ComplexNode { name = 'ComplexNode'; }
class AnotherNode { name = 'Another'; }
class NoExtensionNode { name = 'NoExtension'; }
mockRequire.mockImplementation((path: string) => {
if (path.includes('ComplexNode')) return { default: ComplexNode };
if (path.includes('Another')) return { default: AnotherNode };
if (path.includes('NoExtension')) return { default: NoExtensionNode };
return packageJson;
});
const loader = await createLoaderWithMocks();
const results = await loader['loadPackageNodes']('test-package', 'test-package', packageJson);
expect(results).toHaveLength(3);
expect(results[0].nodeName).toBe('ComplexNode');
expect(results[1].nodeName).toBe('Another');
expect(results[2].nodeName).toBe('NoExtension');
});
it('should handle nodes that fail to load', async () => {
const packageJson = {
n8n: {
nodes: [
'dist/nodes/Good/Good.node.js',
'dist/nodes/Bad/Bad.node.js'
]
}
};
class GoodNode { name = 'Good'; }
mockRequire.mockImplementation((path: string) => {
if (path.includes('Good.node.js')) return { default: GoodNode };
if (path.includes('Bad.node.js')) throw new Error('Module parse error');
return packageJson;
});
mockRequireResolve.mockImplementation((path: string) => {
if (path.includes('Bad.node.js')) throw new Error('Cannot resolve module');
return path;
});
const loader = await createLoaderWithMocks();
const results = await loader['loadPackageNodes']('test-package', 'test-package', packageJson);
expect(results).toHaveLength(1);
expect(results[0].nodeName).toBe('Good');
expect(consoleErrorSpy).toHaveBeenCalledWith(
' โ Failed to load node from test-package/dist/nodes/Bad/Bad.node.js:',
'Cannot resolve module'
);
});
it('should warn when no valid export is found', async () => {
const packageJson = {
n8n: {
nodes: ['dist/nodes/Empty/Empty.node.js']
}
};
mockRequire.mockImplementation((path: string) => {
if (path.includes('Empty.node.js')) return {}; // Empty exports
return packageJson;
});
const loader = await createLoaderWithMocks();
const results = await loader['loadPackageNodes']('test-package', 'test-package', packageJson);
expect(results).toHaveLength(0);
expect(consoleWarnSpy).toHaveBeenCalledWith(
' โ No valid export found for Empty in test-package'
);
});
});
describe('loadPackageNodes - object format', () => {
it('should load nodes from object format', async () => {
const packageJson = {
n8n: {
nodes: {
'FirstNode': 'dist/nodes/First.node.js',
'SecondNode': 'dist/nodes/Second.node.js'
}
}
};
class FirstNode { name = 'First'; }
class SecondNode { name = 'Second'; }
mockRequire.mockImplementation((path: string) => {
if (path.includes('First.node.js')) return { default: FirstNode };
if (path.includes('Second.node.js')) return { default: SecondNode };
return packageJson;
});
const loader = await createLoaderWithMocks();
const results = await loader['loadPackageNodes']('test-package', 'test-package', packageJson);
expect(results).toHaveLength(2);
expect(results).toContainEqual({
packageName: 'test-package',
nodeName: 'FirstNode',
NodeClass: FirstNode
});
expect(results).toContainEqual({
packageName: 'test-package',
nodeName: 'SecondNode',
NodeClass: SecondNode
});
});
it('should handle different export patterns in object format', async () => {
const packageJson = {
n8n: {
nodes: {
'DefaultExport': 'dist/default.js',
'NamedExport': 'dist/named.js',
'ObjectExport': 'dist/object.js'
}
}
};
class DefaultNode { name = 'Default'; }
class NamedNode { name = 'Named'; }
class ObjectNode { name = 'Object'; }
mockRequire.mockImplementation((path: string) => {
if (path.includes('default.js')) return { default: DefaultNode };
if (path.includes('named.js')) return { NamedExport: NamedNode };
if (path.includes('object.js')) return { SomeOtherExport: ObjectNode };
return packageJson;
});
const loader = await createLoaderWithMocks();
const results = await loader['loadPackageNodes']('test-package', 'test-package', packageJson);
expect(results).toHaveLength(3);
expect(results[0].NodeClass).toBe(DefaultNode);
expect(results[1].NodeClass).toBe(NamedNode);
expect(results[2].NodeClass).toBe(ObjectNode);
});
it('should handle errors in object format', async () => {
const packageJson = {
n8n: {
nodes: {
'WorkingNode': 'dist/working.js',
'BrokenNode': 'dist/broken.js'
}
}
};
class WorkingNode { name = 'Working'; }
mockRequire.mockImplementation((path: string) => {
if (path.includes('working.js')) return { default: WorkingNode };
if (path.includes('broken.js')) throw new Error('Syntax error');
return packageJson;
});
mockRequireResolve.mockImplementation((path: string) => {
if (path.includes('broken.js')) throw new Error('Module not found');
return path;
});
const loader = await createLoaderWithMocks();
const results = await loader['loadPackageNodes']('test-package', 'test-package', packageJson);
expect(results).toHaveLength(1);
expect(results[0].nodeName).toBe('WorkingNode');
expect(consoleErrorSpy).toHaveBeenCalledWith(
' โ Failed to load node BrokenNode from test-package:',
'Module not found'
);
});
});
describe('edge cases', () => {
it('should handle empty nodes array', async () => {
const packageJson = {
n8n: {
nodes: []
}
};
const loader = await createLoaderWithMocks();
const results = await loader['loadPackageNodes']('test-package', 'test-package', packageJson);
expect(results).toHaveLength(0);
});
it('should handle empty nodes object', async () => {
const packageJson = {
n8n: {
nodes: {}
}
};
const loader = await createLoaderWithMocks();
const results = await loader['loadPackageNodes']('test-package', 'test-package', packageJson);
expect(results).toHaveLength(0);
});
it('should handle package.json without n8n property', async () => {
const packageJson = {};
const loader = await createLoaderWithMocks();
const results = await loader['loadPackageNodes']('test-package', 'test-package', packageJson);
expect(results).toHaveLength(0);
});
it('should handle malformed node paths', async () => {
const packageJson = {
n8n: {
nodes: [
'', // empty string
null, // null value
undefined, // undefined value
123, // number instead of string
'valid/path/Node.node.js'
]
}
};
class ValidNode { name = 'Valid'; }
mockRequire.mockImplementation((path: string) => {
if (path.includes('valid/path')) return { default: ValidNode };
return packageJson;
});
mockRequireResolve.mockImplementation((path: string) => {
if (path.includes('valid/path')) return path;
throw new Error('Invalid path');
});
const loader = await createLoaderWithMocks();
const results = await loader['loadPackageNodes']('test-package', 'test-package', packageJson);
// Only the valid node should be loaded
expect(results).toHaveLength(1);
expect(results[0].nodeName).toBe('Node');
});
it('should handle circular references in exports', async () => {
const packageJson = {
n8n: {
nodes: ['dist/circular.js']
}
};
const circularExport: any = { name: 'Circular' };
circularExport.self = circularExport; // Create circular reference
mockRequire.mockImplementation((path: string) => {
if (path.includes('circular.js')) return { default: circularExport };
return packageJson;
});
const loader = await createLoaderWithMocks();
const results = await loader['loadPackageNodes']('test-package', 'test-package', packageJson);
expect(results).toHaveLength(1);
expect(results[0].NodeClass).toBe(circularExport);
});
it('should handle very long file paths', async () => {
const longPath = 'dist/' + 'very/'.repeat(50) + 'deep/LongPathNode.node.js';
const packageJson = {
n8n: {
nodes: [longPath]
}
};
class LongPathNode { name = 'LongPath'; }
mockRequire.mockImplementation((path: string) => {
if (path.includes('LongPathNode')) return { default: LongPathNode };
return packageJson;
});
const loader = await createLoaderWithMocks();
const results = await loader['loadPackageNodes']('test-package', 'test-package', packageJson);
expect(results).toHaveLength(1);
expect(results[0].nodeName).toBe('LongPathNode');
});
it('should handle special characters in node names', async () => {
const packageJson = {
n8n: {
nodes: [
'dist/nodes/Node-With-Dashes.node.js',
'dist/nodes/Node_With_Underscores.node.js',
'dist/nodes/Node.With.Dots.node.js',
'dist/nodes/Node@Special.node.js'
]
}
};
class DashNode { name = 'Dash'; }
class UnderscoreNode { name = 'Underscore'; }
class DotNode { name = 'Dot'; }
class SpecialNode { name = 'Special'; }
mockRequire.mockImplementation((path: string) => {
if (path.includes('Node-With-Dashes')) return { default: DashNode };
if (path.includes('Node_With_Underscores')) return { default: UnderscoreNode };
if (path.includes('Node.With.Dots')) return { default: DotNode };
if (path.includes('Node@Special')) return { default: SpecialNode };
return packageJson;
});
const loader = await createLoaderWithMocks();
const results = await loader['loadPackageNodes']('test-package', 'test-package', packageJson);
expect(results).toHaveLength(4);
expect(results[0].nodeName).toBe('Node-With-Dashes');
expect(results[1].nodeName).toBe('Node_With_Underscores');
expect(results[2].nodeName).toBe('Node.With.Dots');
expect(results[3].nodeName).toBe('Node@Special');
});
it('should handle mixed array and object in nodes (invalid but defensive)', async () => {
const packageJson = {
n8n: {
nodes: ['array-node.js'] as any // TypeScript would prevent this, but we test runtime behavior
}
};
// Simulate someone accidentally mixing formats
(packageJson.n8n.nodes as any).CustomNode = 'object-node.js';
class ArrayNode { name = 'Array'; }
class ObjectNode { name = 'Object'; }
mockRequire.mockImplementation((path: string) => {
if (path.includes('array-node')) return { default: ArrayNode };
if (path.includes('object-node')) return { default: ObjectNode };
return packageJson;
});
const loader = await createLoaderWithMocks();
const results = await loader['loadPackageNodes']('test-package', 'test-package', packageJson);
// Should treat as array and only load the array item
expect(results).toHaveLength(1);
expect(results[0].NodeClass).toBe(ArrayNode);
});
});
describe('console output verification', () => {
it('should log correct messages for successful loads', async () => {
const packageJson = {
n8n: {
nodes: ['dist/Success.node.js']
}
};
class SuccessNode { name = 'Success'; }
mockRequire.mockImplementation((path: string) => {
if (path.includes('Success')) return { default: SuccessNode };
return packageJson;
});
const loader = await createLoaderWithMocks();
await loader['loadPackageNodes']('test-pkg', 'test-pkg', packageJson);
expect(consoleLogSpy).toHaveBeenCalledWith(' โ Loaded Success from test-pkg');
});
it('should log package loading progress', async () => {
mockRequire.mockImplementation(() => {
throw new Error('Not found');
});
const loader = await createLoaderWithMocks();
await loader.loadAllNodes();
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('๐ฆ Loading package: n8n-nodes-base')
);
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining('๐ฆ Loading package: @n8n/n8n-nodes-langchain')
);
});
});
});