n8n/packages/@n8n/workflow-sdk/src/workflow-builder/subnode-utils.test.ts
Mutasem Aldmour a0664a1add
feat(core): Expand workflow-sdk test fixtures from 500 to 2000 workflows and fix codegen bugs (#26041)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 10:09:16 +00:00

578 lines
17 KiB
TypeScript

/**
* Tests for subnode utilities
*/
import { addNodeWithSubnodes } from './subnode-utils';
import type { GraphNode, NodeInstance } from '../types/base';
// Helper to create a minimal node instance
function createNode(
overrides: Partial<NodeInstance<string, string, unknown>> = {},
): NodeInstance<string, string, unknown> {
return {
type: overrides.type ?? 'n8n-nodes-base.set',
version: 'v1',
id: overrides.id ?? 'node-id',
name: overrides.name ?? 'Test Node',
config: overrides.config ?? {},
update: () => ({}) as NodeInstance<string, string, unknown>,
then: () => ({}) as never,
to: () => ({}) as never,
input: () => ({
_isInputTarget: true,
node: {} as NodeInstance<string, string, unknown>,
inputIndex: 0,
}),
output: () => ({}) as never,
onError: () => ({}) as never,
getConnections: () => [],
} as NodeInstance<string, string, unknown>;
}
// Helper to create a subnode (model, memory, tool, etc.)
function createSubnode(
overrides: Partial<NodeInstance<string, string, unknown>> = {},
): NodeInstance<string, string, unknown> {
return {
...createNode(overrides),
_subnodeType: 'model', // Required for subnode types
} as unknown as NodeInstance<string, string, unknown>;
}
describe('addNodeWithSubnodes', () => {
describe('validation', () => {
it('returns undefined for null node', () => {
const nodes = new Map<string, GraphNode>();
const result = addNodeWithSubnodes(
nodes,
null as unknown as NodeInstance<string, string, unknown>,
);
expect(result).toBeUndefined();
});
it('returns undefined for node without type', () => {
const nodes = new Map<string, GraphNode>();
const node = { name: 'Test' } as NodeInstance<string, string, unknown>;
const result = addNodeWithSubnodes(nodes, node);
expect(result).toBeUndefined();
});
it('returns undefined for node without name', () => {
const nodes = new Map<string, GraphNode>();
const node = { type: 'test' } as NodeInstance<string, string, unknown>;
const result = addNodeWithSubnodes(nodes, node);
expect(result).toBeUndefined();
});
});
describe('adding nodes', () => {
it('adds node to the map and returns its name', () => {
const nodes = new Map<string, GraphNode>();
const node = createNode({ name: 'My Node' });
const result = addNodeWithSubnodes(nodes, node);
expect(result).toBe('My Node');
expect(nodes.has('My Node')).toBe(true);
expect(nodes.get('My Node')?.instance).toBe(node);
});
it('returns existing key if same instance already in map', () => {
const node = createNode({ name: 'Original Name' });
const nodes = new Map<string, GraphNode>();
nodes.set('Different Key', {
instance: node,
connections: new Map(),
});
const result = addNodeWithSubnodes(nodes, node);
expect(result).toBe('Different Key');
expect(nodes.size).toBe(1); // No new node added
});
it('generates unique name for duplicate node names', () => {
const nodes = new Map<string, GraphNode>();
const existingNode = createNode({ id: 'existing', name: 'Process' });
nodes.set('Process', {
instance: existingNode,
connections: new Map(),
});
const newNode = createNode({ id: 'new', name: 'Process' });
const result = addNodeWithSubnodes(nodes, newNode);
expect(result).toBe('Process 1');
expect(nodes.size).toBe(2);
expect(nodes.get('Process 1')?.instance).toBe(newNode);
});
it('initializes main connections map', () => {
const nodes = new Map<string, GraphNode>();
const node = createNode({ name: 'Test' });
addNodeWithSubnodes(nodes, node);
const graphNode = nodes.get('Test');
expect(graphNode?.connections.has('main')).toBe(true);
});
});
describe('subnodes', () => {
it('adds model subnode with ai_languageModel connection', () => {
const nodes = new Map<string, GraphNode>();
const modelSubnode = createSubnode({ name: 'GPT Model', type: 'lmChatOpenAi' });
const agentNode = createNode({
name: 'Agent',
type: 'agent',
config: {
subnodes: {
model: modelSubnode,
},
} as unknown as NodeInstance<string, string, unknown>['config'],
});
addNodeWithSubnodes(nodes, agentNode);
expect(nodes.has('GPT Model')).toBe(true);
const modelGraphNode = nodes.get('GPT Model');
const aiConns = modelGraphNode?.connections.get('ai_languageModel');
expect(aiConns?.get(0)).toContainEqual({
node: 'Agent',
type: 'ai_languageModel',
index: 0,
});
});
it('adds memory subnode with ai_memory connection', () => {
const nodes = new Map<string, GraphNode>();
const memorySubnode = createSubnode({ name: 'Buffer Memory', type: 'memoryBufferWindow' });
const agentNode = createNode({
name: 'Agent',
config: {
subnodes: {
memory: memorySubnode,
},
} as unknown as NodeInstance<string, string, unknown>['config'],
});
addNodeWithSubnodes(nodes, agentNode);
expect(nodes.has('Buffer Memory')).toBe(true);
const memoryGraphNode = nodes.get('Buffer Memory');
const aiConns = memoryGraphNode?.connections.get('ai_memory');
expect(aiConns?.get(0)).toContainEqual({
node: 'Agent',
type: 'ai_memory',
index: 0,
});
});
it('adds tool subnodes with ai_tool connections all at index 0', () => {
const nodes = new Map<string, GraphNode>();
const tool1 = createSubnode({ name: 'Calculator', type: 'toolCalculator' });
const tool2 = createSubnode({ name: 'Wikipedia', type: 'toolWikipedia' });
const tool3 = createSubnode({ name: 'Code', type: 'toolCode' });
const agentNode = createNode({
name: 'Agent',
config: {
subnodes: {
tools: [tool1, tool2, tool3],
},
} as unknown as NodeInstance<string, string, unknown>['config'],
});
addNodeWithSubnodes(nodes, agentNode);
expect(nodes.has('Calculator')).toBe(true);
expect(nodes.has('Wikipedia')).toBe(true);
expect(nodes.has('Code')).toBe(true);
// All tools should target index 0 (single ai_tool input on the agent)
const calcConns = nodes.get('Calculator')?.connections.get('ai_tool');
expect(calcConns?.get(0)).toContainEqual({
node: 'Agent',
type: 'ai_tool',
index: 0,
});
const wikiConns = nodes.get('Wikipedia')?.connections.get('ai_tool');
expect(wikiConns?.get(0)).toContainEqual({
node: 'Agent',
type: 'ai_tool',
index: 0,
});
const codeConns = nodes.get('Code')?.connections.get('ai_tool');
expect(codeConns?.get(0)).toContainEqual({
node: 'Agent',
type: 'ai_tool',
index: 0,
});
});
it('adds vectorStore subnode with ai_vectorStore connection', () => {
const nodes = new Map<string, GraphNode>();
const vectorStore = createSubnode({ name: 'Pinecone', type: 'vectorStorePinecone' });
const retrieverNode = createNode({
name: 'Retriever',
config: {
subnodes: {
vectorStore,
},
} as unknown as NodeInstance<string, string, unknown>['config'],
});
addNodeWithSubnodes(nodes, retrieverNode);
expect(nodes.has('Pinecone')).toBe(true);
const vsConns = nodes.get('Pinecone')?.connections.get('ai_vectorStore');
expect(vsConns?.get(0)).toContainEqual({
node: 'Retriever',
type: 'ai_vectorStore',
index: 0,
});
});
it('handles embedding subnode (singular key)', () => {
const nodes = new Map<string, GraphNode>();
const embedding = createSubnode({ name: 'OpenAI Embeddings', type: 'embeddingsOpenAi' });
const vectorStore = createNode({
name: 'Vector Store',
config: {
subnodes: {
embedding,
},
} as unknown as NodeInstance<string, string, unknown>['config'],
});
addNodeWithSubnodes(nodes, vectorStore);
expect(nodes.has('OpenAI Embeddings')).toBe(true);
const embConns = nodes.get('OpenAI Embeddings')?.connections.get('ai_embedding');
expect(embConns?.get(0)).toContainEqual({
node: 'Vector Store',
type: 'ai_embedding',
index: 0,
});
});
it('adds multiple embedding subnodes all at index 0', () => {
const nodes = new Map<string, GraphNode>();
const emb1 = createSubnode({ name: 'Embeddings 1', type: 'embeddingsOpenAi' });
const emb2 = createSubnode({ name: 'Embeddings 2', type: 'embeddingsOpenAi' });
const vectorStore = createNode({
name: 'Vector Store',
config: {
subnodes: {
embedding: [emb1, emb2],
},
} as unknown as NodeInstance<string, string, unknown>['config'],
});
addNodeWithSubnodes(nodes, vectorStore);
const emb1Conns = nodes.get('Embeddings 1')?.connections.get('ai_embedding');
expect(emb1Conns?.get(0)).toContainEqual({
node: 'Vector Store',
type: 'ai_embedding',
index: 0,
});
const emb2Conns = nodes.get('Embeddings 2')?.connections.get('ai_embedding');
expect(emb2Conns?.get(0)).toContainEqual({
node: 'Vector Store',
type: 'ai_embedding',
index: 0,
});
});
it('adds multiple document loader subnodes all at index 0', () => {
const nodes = new Map<string, GraphNode>();
const doc1 = createSubnode({ name: 'Doc Loader 1', type: 'documentDefaultDataLoader' });
const doc2 = createSubnode({ name: 'Doc Loader 2', type: 'documentDefaultDataLoader' });
const vectorStore = createNode({
name: 'Vector Store',
config: {
subnodes: {
documentLoader: [doc1, doc2],
},
} as unknown as NodeInstance<string, string, unknown>['config'],
});
addNodeWithSubnodes(nodes, vectorStore);
const doc1Conns = nodes.get('Doc Loader 1')?.connections.get('ai_document');
expect(doc1Conns?.get(0)).toContainEqual({
node: 'Vector Store',
type: 'ai_document',
index: 0,
});
const doc2Conns = nodes.get('Doc Loader 2')?.connections.get('ai_document');
expect(doc2Conns?.get(0)).toContainEqual({
node: 'Vector Store',
type: 'ai_document',
index: 0,
});
});
it('keeps ai_languageModel array index for primary/fallback', () => {
const nodes = new Map<string, GraphNode>();
const model1 = createSubnode({ name: 'Primary Model', type: 'lmChatOpenAi' });
const model2 = createSubnode({ name: 'Fallback Model', type: 'lmChatOpenAi' });
const agentNode = createNode({
name: 'Agent',
config: {
subnodes: {
model: [model1, model2],
},
} as unknown as NodeInstance<string, string, unknown>['config'],
});
addNodeWithSubnodes(nodes, agentNode);
const primaryConns = nodes.get('Primary Model')?.connections.get('ai_languageModel');
expect(primaryConns?.get(0)).toContainEqual({
node: 'Agent',
type: 'ai_languageModel',
index: 0,
});
const fallbackConns = nodes.get('Fallback Model')?.connections.get('ai_languageModel');
expect(fallbackConns?.get(0)).toContainEqual({
node: 'Agent',
type: 'ai_languageModel',
index: 1,
});
});
it('handles embeddings subnode (plural key)', () => {
const nodes = new Map<string, GraphNode>();
const embedding = createSubnode({ name: 'OpenAI Embeddings', type: 'embeddingsOpenAi' });
const vectorStore = createNode({
name: 'Vector Store',
config: {
subnodes: {
embeddings: embedding,
},
} as unknown as NodeInstance<string, string, unknown>['config'],
});
addNodeWithSubnodes(nodes, vectorStore);
expect(nodes.has('OpenAI Embeddings')).toBe(true);
});
it('merges AI connection when subnode already exists', () => {
const nodes = new Map<string, GraphNode>();
const modelSubnode = createSubnode({ name: 'Shared Model', type: 'lmChatOpenAi' });
// First agent adds the model
const agent1 = createNode({
name: 'Agent 1',
config: { subnodes: { model: modelSubnode } } as unknown as NodeInstance<
string,
string,
unknown
>['config'],
});
addNodeWithSubnodes(nodes, agent1);
// Second agent shares the same model
const agent2 = createNode({
name: 'Agent 2',
config: { subnodes: { model: modelSubnode } } as unknown as NodeInstance<
string,
string,
unknown
>['config'],
});
addNodeWithSubnodes(nodes, agent2);
// Model should have connections to both agents
const modelConns = nodes.get('Shared Model')?.connections.get('ai_languageModel');
const connections = modelConns?.get(0) ?? [];
expect(connections).toContainEqual({ node: 'Agent 1', type: 'ai_languageModel', index: 0 });
expect(connections).toContainEqual({ node: 'Agent 2', type: 'ai_languageModel', index: 0 });
});
});
describe('duplicate parent names with shared subnodes', () => {
it('processes subnodes for auto-renamed nodes', () => {
const nodes = new Map<string, GraphNode>();
const sharedModel = createSubnode({ name: 'Shared Model', type: 'lmChatOpenAi' });
// Two agents with IDENTICAL names, sharing the same model
const agent1 = createNode({
name: 'Agent',
config: { subnodes: { model: sharedModel } } as unknown as NodeInstance<
string,
string,
unknown
>['config'],
});
const agent2 = createNode({
name: 'Agent',
config: { subnodes: { model: sharedModel } } as unknown as NodeInstance<
string,
string,
unknown
>['config'],
});
const key1 = addNodeWithSubnodes(nodes, agent1);
const key2 = addNodeWithSubnodes(nodes, agent2);
// First agent keeps its name, second gets renamed
expect(key1).toBe('Agent');
expect(key2).toBe('Agent 1');
// Shared model should exist and connect to BOTH agents using their map keys
const modelConns = nodes.get('Shared Model')?.connections.get('ai_languageModel');
const connections = modelConns?.get(0) ?? [];
expect(connections).toContainEqual({
node: 'Agent',
type: 'ai_languageModel',
index: 0,
});
expect(connections).toContainEqual({
node: 'Agent 1',
type: 'ai_languageModel',
index: 0,
});
});
});
describe('plain object subnodes (no top-level .name)', () => {
it('derives name from config.name for plain object subnodes', () => {
const nodes = new Map<string, GraphNode>();
// Plain object subnode without top-level .name, simulating inline subnode definition
const plainDocLoader = {
type: '@n8n/n8n-nodes-langchain.documentDefaultDataLoader',
version: '1.1',
config: {
name: 'Load My Company Data',
parameters: {},
},
} as unknown as NodeInstance<string, string, unknown>;
const vectorStore = createNode({
name: 'Store Data',
type: 'vectorStorePinecone',
config: {
subnodes: {
documentLoader: plainDocLoader,
},
} as unknown as NodeInstance<string, string, unknown>['config'],
});
addNodeWithSubnodes(nodes, vectorStore);
// Should NOT have an undefined key in the map
expect(nodes.has(undefined as unknown as string)).toBe(false);
// Should use config.name as the key
expect(nodes.has('Load My Company Data')).toBe(true);
// Should have the correct AI connection
const docConns = nodes.get('Load My Company Data')?.connections.get('ai_document');
expect(docConns?.get(0)).toContainEqual({
node: 'Store Data',
type: 'ai_document',
index: 0,
});
});
it('derives name from config.name for nested plain object subnodes', () => {
const nodes = new Map<string, GraphNode>();
// Plain object embedding nested inside a tool subnode
const plainEmbedding = {
type: '@n8n/n8n-nodes-langchain.embeddingsOpenAi',
version: '1.2',
config: {
name: 'OpenAI Embeddings',
parameters: { model: 'text-embedding-3-small' },
},
} as unknown as NodeInstance<string, string, unknown>;
const toolSubnode = createSubnode({
name: 'RAG Tool',
type: 'vectorStorePinecone',
config: {
subnodes: {
embedding: plainEmbedding,
},
} as unknown as NodeInstance<string, string, unknown>['config'],
});
const agentNode = createNode({
name: 'Agent',
config: {
subnodes: {
tools: [toolSubnode],
},
} as unknown as NodeInstance<string, string, unknown>['config'],
});
addNodeWithSubnodes(nodes, agentNode);
// Should NOT have an undefined key
expect(nodes.has(undefined as unknown as string)).toBe(false);
// Nested plain object embedding should use config.name
expect(nodes.has('OpenAI Embeddings')).toBe(true);
});
});
describe('nested subnodes', () => {
it('processes nested subnodes recursively', () => {
const nodes = new Map<string, GraphNode>();
const embedding = createSubnode({ name: 'Embedding', type: 'embeddingsOpenAi' });
const vectorStore = createSubnode({
name: 'Vector Store',
type: 'vectorStorePinecone',
config: {
subnodes: {
embedding,
},
} as unknown as NodeInstance<string, string, unknown>['config'],
});
const retrieverNode = createNode({
name: 'Retriever',
config: {
subnodes: {
vectorStore,
},
} as unknown as NodeInstance<string, string, unknown>['config'],
});
addNodeWithSubnodes(nodes, retrieverNode);
// All three nodes should be in the map
expect(nodes.has('Retriever')).toBe(true);
expect(nodes.has('Vector Store')).toBe(true);
expect(nodes.has('Embedding')).toBe(true);
// Vector Store -> Retriever connection
const vsConns = nodes.get('Vector Store')?.connections.get('ai_vectorStore');
expect(vsConns?.get(0)).toContainEqual({
node: 'Retriever',
type: 'ai_vectorStore',
index: 0,
});
// Embedding -> Vector Store connection
const embConns = nodes.get('Embedding')?.connections.get('ai_embedding');
expect(embConns?.get(0)).toContainEqual({
node: 'Vector Store',
type: 'ai_embedding',
index: 0,
});
});
});
});