mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 01:07:04 +02:00
1589 lines
50 KiB
TypeScript
1589 lines
50 KiB
TypeScript
/**
|
|
* Tests for WorkflowBuilder plugin integration (Phase 6)
|
|
*
|
|
* These tests verify that the plugin system is properly integrated with
|
|
* WorkflowBuilderImpl, allowing plugins to participate in validation,
|
|
* composite handling, and serialization.
|
|
*/
|
|
|
|
import type { NodeInstance, WorkflowJSON, IfElseComposite, GraphNode } from './types/base';
|
|
import { workflow } from './workflow-builder';
|
|
import { splitInBatches } from './workflow-builder/control-flow-builders/split-in-batches';
|
|
import { node, trigger, ifElse, switchCase } from './workflow-builder/node-builders/node-builder';
|
|
import { ifElseHandler } from './workflow-builder/plugins/composite-handlers/if-else-handler';
|
|
import { splitInBatchesHandler } from './workflow-builder/plugins/composite-handlers/split-in-batches-handler';
|
|
import { switchCaseHandler } from './workflow-builder/plugins/composite-handlers/switch-case-handler';
|
|
import { registerDefaultPlugins } from './workflow-builder/plugins/defaults';
|
|
import { PluginRegistry, pluginRegistry } from './workflow-builder/plugins/registry';
|
|
import { jsonSerializer } from './workflow-builder/plugins/serializers/json-serializer';
|
|
import type {
|
|
ValidatorPlugin,
|
|
PluginContext,
|
|
SerializerPlugin,
|
|
CompositeHandlerPlugin,
|
|
MutablePluginContext,
|
|
} from './workflow-builder/plugins/types';
|
|
import { agentValidator } from './workflow-builder/plugins/validators/agent-validator';
|
|
import { disconnectedNodeValidator } from './workflow-builder/plugins/validators/disconnected-node-validator';
|
|
import { maxNodesValidator } from './workflow-builder/plugins/validators/max-nodes-validator';
|
|
import { missingTriggerValidator } from './workflow-builder/plugins/validators/missing-trigger-validator';
|
|
import { noNodesValidator } from './workflow-builder/plugins/validators/no-nodes-validator';
|
|
|
|
// Helper to create mock validators
|
|
function createMockValidator(
|
|
id: string,
|
|
nodeTypes: string[] = [],
|
|
validateNodeFn: ValidatorPlugin['validateNode'] = () => [],
|
|
): ValidatorPlugin {
|
|
return {
|
|
id,
|
|
name: `Mock Validator ${id}`,
|
|
nodeTypes,
|
|
validateNode: jest.fn(validateNodeFn),
|
|
};
|
|
}
|
|
|
|
describe('WorkflowBuilder plugin integration', () => {
|
|
let testRegistry: PluginRegistry;
|
|
|
|
beforeEach(() => {
|
|
testRegistry = new PluginRegistry();
|
|
});
|
|
|
|
describe('validate() with plugins', () => {
|
|
it('runs registered validators for matching node types', () => {
|
|
const mockValidateNode = jest.fn().mockReturnValue([]);
|
|
const mockValidator = createMockValidator(
|
|
'test:mock',
|
|
['n8n-nodes-base.set'],
|
|
mockValidateNode,
|
|
);
|
|
testRegistry.registerValidator(mockValidator);
|
|
|
|
const setNode = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Set Data', parameters: { values: [] } },
|
|
});
|
|
|
|
const wf = workflow('test', 'Test', { registry: testRegistry }).add(
|
|
trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
}).to(setNode),
|
|
);
|
|
|
|
wf.validate();
|
|
|
|
expect(mockValidateNode).toHaveBeenCalled();
|
|
});
|
|
|
|
it('collects issues from all matching validators', () => {
|
|
const validator1 = createMockValidator('test:v1', [], () => [
|
|
{ code: 'V1_ISSUE', message: 'Issue 1', severity: 'warning' },
|
|
]);
|
|
const validator2 = createMockValidator('test:v2', [], () => [
|
|
{ code: 'V2_ISSUE', message: 'Issue 2', severity: 'error' },
|
|
]);
|
|
testRegistry.registerValidator(validator1);
|
|
testRegistry.registerValidator(validator2);
|
|
|
|
const wf = workflow('test', 'Test', { registry: testRegistry }).add(
|
|
trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
}),
|
|
);
|
|
|
|
const result = wf.validate();
|
|
|
|
// Cast to any to allow checking custom plugin codes
|
|
expect(result.warnings.some((w) => (w.code as string) === 'V1_ISSUE')).toBe(true);
|
|
expect(result.errors.some((e) => (e.code as string) === 'V2_ISSUE')).toBe(true);
|
|
});
|
|
|
|
it('validators receive correct PluginContext', () => {
|
|
let receivedCtx: PluginContext | undefined;
|
|
const mockValidator = createMockValidator('test:ctx', [], (_node, _graphNode, ctx) => {
|
|
receivedCtx = ctx;
|
|
return [];
|
|
});
|
|
testRegistry.registerValidator(mockValidator);
|
|
|
|
const wf = workflow('wf-123', 'My Workflow', { registry: testRegistry }).add(
|
|
trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
}),
|
|
);
|
|
|
|
wf.validate();
|
|
|
|
expect(receivedCtx).toBeDefined();
|
|
expect(receivedCtx!.workflowId).toBe('wf-123');
|
|
expect(receivedCtx!.workflowName).toBe('My Workflow');
|
|
expect(receivedCtx!.nodes).toBeDefined();
|
|
});
|
|
|
|
it('validateWorkflow() hook is called after node validation', () => {
|
|
const callOrder: string[] = [];
|
|
const mockValidator: ValidatorPlugin = {
|
|
id: 'test:hooks',
|
|
name: 'Hook Validator',
|
|
validateNode: () => {
|
|
callOrder.push('validateNode');
|
|
return [];
|
|
},
|
|
validateWorkflow: () => {
|
|
callOrder.push('validateWorkflow');
|
|
return [];
|
|
},
|
|
};
|
|
testRegistry.registerValidator(mockValidator);
|
|
|
|
const wf = workflow('test', 'Test', { registry: testRegistry }).add(
|
|
trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
}),
|
|
);
|
|
|
|
wf.validate();
|
|
|
|
expect(callOrder).toContain('validateNode');
|
|
expect(callOrder).toContain('validateWorkflow');
|
|
// validateWorkflow should be called after all validateNode calls
|
|
const nodeIdx = callOrder.indexOf('validateNode');
|
|
const workflowIdx = callOrder.indexOf('validateWorkflow');
|
|
expect(workflowIdx).toBeGreaterThan(nodeIdx);
|
|
});
|
|
|
|
it('skips validators that do not match node type', () => {
|
|
const agentValidator = createMockValidator(
|
|
'test:agent',
|
|
['@n8n/n8n-nodes-langchain.agent'],
|
|
() => [{ code: 'AGENT_ISSUE', message: 'Agent issue', severity: 'warning' }],
|
|
);
|
|
testRegistry.registerValidator(agentValidator);
|
|
|
|
// Add a non-agent node
|
|
const wf = workflow('test', 'Test', { registry: testRegistry }).add(
|
|
trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
}),
|
|
);
|
|
|
|
const result = wf.validate();
|
|
|
|
// Agent validator should not have been called
|
|
expect(agentValidator.validateNode).not.toHaveBeenCalled();
|
|
expect(result.warnings.some((w) => (w.code as string) === 'AGENT_ISSUE')).toBe(false);
|
|
});
|
|
|
|
it('validators with empty nodeTypes run on all nodes', () => {
|
|
const universalValidator = createMockValidator('test:universal', [], () => []);
|
|
testRegistry.registerValidator(universalValidator);
|
|
|
|
const wf = workflow('test', 'Test', { registry: testRegistry }).add(
|
|
trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
}).to(
|
|
node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Set' },
|
|
}),
|
|
),
|
|
);
|
|
|
|
wf.validate();
|
|
|
|
// Should be called once for each node (2 nodes)
|
|
expect(universalValidator.validateNode).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe('toFormat()', () => {
|
|
it('returns serialized output for registered format', () => {
|
|
testRegistry.registerSerializer(jsonSerializer);
|
|
|
|
const wf = workflow('wf-1', 'Test', { registry: testRegistry }).add(
|
|
trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
}),
|
|
);
|
|
|
|
const result = wf.toFormat<WorkflowJSON>('json');
|
|
|
|
expect(result.id).toBe('wf-1');
|
|
expect(result.name).toBe('Test');
|
|
});
|
|
|
|
it('throws for unknown format', () => {
|
|
const wf = workflow('test', 'Test', { registry: testRegistry });
|
|
|
|
expect(() => wf.toFormat('yaml')).toThrow("No serializer registered for format 'yaml'");
|
|
});
|
|
|
|
it('custom serializer can transform workflow', () => {
|
|
const customSerializer: SerializerPlugin<string> = {
|
|
id: 'test:custom',
|
|
name: 'Custom Serializer',
|
|
format: 'custom',
|
|
serialize: (ctx) => `Workflow: ${ctx.workflowName} (${ctx.nodes.size} nodes)`,
|
|
};
|
|
testRegistry.registerSerializer(customSerializer);
|
|
|
|
const wf = workflow('test', 'My Flow', { registry: testRegistry }).add(
|
|
trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
}),
|
|
);
|
|
|
|
const result = wf.toFormat<string>('custom');
|
|
|
|
expect(result).toBe('Workflow: My Flow (1 nodes)');
|
|
});
|
|
});
|
|
|
|
describe('workflow() factory with registry option', () => {
|
|
it('accepts registry option', () => {
|
|
const customRegistry = new PluginRegistry();
|
|
const wf = workflow('test', 'Test', { registry: customRegistry });
|
|
|
|
// Should not throw
|
|
expect(wf).toBeDefined();
|
|
});
|
|
|
|
it('uses provided registry for validation', () => {
|
|
const customRegistry = new PluginRegistry();
|
|
const mockValidator = createMockValidator('custom:v1', [], () => [
|
|
{ code: 'CUSTOM_ISSUE', message: 'Custom issue', severity: 'warning' },
|
|
]);
|
|
customRegistry.registerValidator(mockValidator);
|
|
|
|
const wf = workflow('test', 'Test', { registry: customRegistry }).add(
|
|
trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
}),
|
|
);
|
|
|
|
const result = wf.validate();
|
|
|
|
expect(result.warnings.some((w) => (w.code as string) === 'CUSTOM_ISSUE')).toBe(true);
|
|
});
|
|
|
|
it('accepts both settings and registry', () => {
|
|
const customRegistry = new PluginRegistry();
|
|
const wf = workflow('test', 'Test', {
|
|
settings: { timezone: 'UTC' },
|
|
registry: customRegistry,
|
|
});
|
|
|
|
const json = wf.toJSON();
|
|
expect(json.settings?.timezone).toBe('UTC');
|
|
});
|
|
});
|
|
|
|
describe('add() with composite handlers', () => {
|
|
it('delegates IfElseComposite to registered handler when handler handles it', () => {
|
|
const mockAddNodes = jest.fn().mockReturnValue('If Node');
|
|
const mockHandler: CompositeHandlerPlugin<IfElseComposite> = {
|
|
id: 'test:if-else',
|
|
name: 'Test If/Else Handler',
|
|
priority: 100,
|
|
canHandle: (input): input is IfElseComposite =>
|
|
input !== null &&
|
|
typeof input === 'object' &&
|
|
'_isIfElseBuilder' in input &&
|
|
(input as { _isIfElseBuilder: boolean })._isIfElseBuilder,
|
|
addNodes: mockAddNodes,
|
|
};
|
|
testRegistry.registerCompositeHandler(mockHandler);
|
|
|
|
// Create an IfElseComposite using the ifElse builder
|
|
const trueNode = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'True Branch', parameters: {} },
|
|
});
|
|
const falseNode = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'False Branch', parameters: {} },
|
|
});
|
|
|
|
const composite = ifElse({ version: 2, config: { name: 'If Node', parameters: {} } }).onTrue!(
|
|
trueNode,
|
|
).onFalse(falseNode);
|
|
|
|
// Add the composite to the workflow
|
|
workflow('test', 'Test', { registry: testRegistry }).add(composite);
|
|
|
|
// Verify the handler was called
|
|
expect(mockAddNodes).toHaveBeenCalled();
|
|
});
|
|
|
|
it('uses global pluginRegistry as fallback when custom registry has no handler', () => {
|
|
// Custom registry has no handlers, but global pluginRegistry does
|
|
// The workflow should use global pluginRegistry as fallback
|
|
const trueNode = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'True Branch', parameters: {} },
|
|
});
|
|
const falseNode = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'False Branch', parameters: {} },
|
|
});
|
|
|
|
const composite = ifElse({ version: 2, config: { name: 'If Node', parameters: {} } }).onTrue!(
|
|
trueNode,
|
|
).onFalse(falseNode);
|
|
|
|
// Create workflow without a custom registry - uses global pluginRegistry
|
|
const wf = workflow('test', 'Test').add(composite);
|
|
|
|
// Verify all nodes were added (global pluginRegistry has core:if-else handler)
|
|
const json = wf.toJSON();
|
|
expect(json.nodes).toHaveLength(3);
|
|
expect(json.nodes.map((n) => n.name)).toContain('If Node');
|
|
expect(json.nodes.map((n) => n.name)).toContain('True Branch');
|
|
expect(json.nodes.map((n) => n.name)).toContain('False Branch');
|
|
});
|
|
|
|
it('handler receives MutablePluginContext with helper methods', () => {
|
|
let receivedCtx: MutablePluginContext | undefined;
|
|
const mockHandler: CompositeHandlerPlugin<IfElseComposite> = {
|
|
id: 'test:ctx-checker',
|
|
name: 'Context Checker Handler',
|
|
priority: 100,
|
|
canHandle: (input): input is IfElseComposite =>
|
|
input !== null &&
|
|
typeof input === 'object' &&
|
|
'_isIfElseBuilder' in input &&
|
|
(input as { _isIfElseBuilder: boolean })._isIfElseBuilder,
|
|
addNodes: (input, ctx) => {
|
|
receivedCtx = ctx;
|
|
// Actually add the if node so workflow doesn't fail
|
|
ctx.addNodeWithSubnodes(input.ifNode);
|
|
return input.ifNode.name;
|
|
},
|
|
};
|
|
testRegistry.registerCompositeHandler(mockHandler);
|
|
|
|
const composite = ifElse({ version: 2, config: { name: 'If Node', parameters: {} } }).onTrue!(
|
|
null,
|
|
).onFalse(null);
|
|
|
|
workflow('wf-123', 'My Workflow', { registry: testRegistry }).add(composite);
|
|
|
|
expect(receivedCtx).toBeDefined();
|
|
expect(receivedCtx!.workflowId).toBe('wf-123');
|
|
expect(receivedCtx!.workflowName).toBe('My Workflow');
|
|
expect(typeof receivedCtx!.addNodeWithSubnodes).toBe('function');
|
|
expect(typeof receivedCtx!.addBranchToGraph).toBe('function');
|
|
});
|
|
});
|
|
|
|
describe('then() with composite handlers', () => {
|
|
it('delegates IfElseComposite to registered handler in then()', () => {
|
|
const mockAddNodes = jest
|
|
.fn()
|
|
.mockImplementation((input: IfElseComposite, ctx: MutablePluginContext) => {
|
|
ctx.addNodeWithSubnodes(input.ifNode);
|
|
return input.ifNode.name;
|
|
});
|
|
const mockHandler: CompositeHandlerPlugin<IfElseComposite> = {
|
|
id: 'test:if-else',
|
|
name: 'Test If/Else Handler',
|
|
priority: 100,
|
|
canHandle: (input): input is IfElseComposite =>
|
|
input !== null &&
|
|
typeof input === 'object' &&
|
|
'_isIfElseBuilder' in input &&
|
|
(input as { _isIfElseBuilder: boolean })._isIfElseBuilder,
|
|
addNodes: mockAddNodes,
|
|
};
|
|
testRegistry.registerCompositeHandler(mockHandler);
|
|
|
|
const startTrigger = trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
});
|
|
const composite = ifElse({ version: 2, config: { name: 'If Node', parameters: {} } }).onTrue!(
|
|
null,
|
|
).onFalse(null);
|
|
|
|
workflow('test', 'Test', { registry: testRegistry }).add(startTrigger).to(composite);
|
|
|
|
expect(mockAddNodes).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('Phase 6.6.1: Unconditional composite handler dispatch', () => {
|
|
it('uses global pluginRegistry.findCompositeHandler when no registry is provided', () => {
|
|
// Import the global registry to spy on it
|
|
|
|
// Spy on the global registry's findCompositeHandler method
|
|
const findCompositeHandlerSpy = jest.spyOn(pluginRegistry, 'findCompositeHandler');
|
|
|
|
// Create a workflow WITHOUT explicitly passing a registry
|
|
// Use ifElse composite which should trigger findCompositeHandler
|
|
const trueBranch = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'True Branch', parameters: {} },
|
|
});
|
|
const falseBranch = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'False Branch', parameters: {} },
|
|
});
|
|
|
|
const composite = ifElse({
|
|
version: 2,
|
|
config: { name: 'If Node', parameters: {} },
|
|
}).onTrue!(trueBranch).onFalse(falseBranch);
|
|
|
|
// Create workflow without registry option
|
|
workflow('test', 'Test').add(composite);
|
|
|
|
// The global pluginRegistry.findCompositeHandler should have been called
|
|
expect(findCompositeHandlerSpy).toHaveBeenCalled();
|
|
|
|
// Restore the spy
|
|
findCompositeHandlerSpy.mockRestore();
|
|
});
|
|
|
|
it('uses global pluginRegistry.findCompositeHandler in then() when no registry is provided', () => {
|
|
const findCompositeHandlerSpy = jest.spyOn(pluginRegistry, 'findCompositeHandler');
|
|
|
|
const startTrigger = trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
});
|
|
|
|
const composite = ifElse({
|
|
version: 2,
|
|
config: { name: 'If Node', parameters: {} },
|
|
}).onTrue!(null).onFalse(null);
|
|
|
|
// Create workflow without registry option and use then()
|
|
workflow('test', 'Test').add(startTrigger).to(composite);
|
|
|
|
expect(findCompositeHandlerSpy).toHaveBeenCalled();
|
|
findCompositeHandlerSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('Phase 6.5: No duplicate validation', () => {
|
|
it('validates each node exactly once per validator (no duplicates)', () => {
|
|
// This test verifies that validation is not duplicated
|
|
// After Phase 6.5, plugin validators run INSTEAD of inline checks, not in addition
|
|
const validateCallCounts: Map<string, number> = new Map();
|
|
|
|
const countingValidator: ValidatorPlugin = {
|
|
id: 'test:counter',
|
|
name: 'Counting Validator',
|
|
nodeTypes: ['n8n-nodes-base.set'],
|
|
validateNode: (nodeInstance) => {
|
|
const nodeName = nodeInstance.name;
|
|
validateCallCounts.set(nodeName, (validateCallCounts.get(nodeName) ?? 0) + 1);
|
|
return [];
|
|
},
|
|
};
|
|
testRegistry.registerValidator(countingValidator);
|
|
|
|
const setNode = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Set Data', parameters: { values: [] } },
|
|
});
|
|
|
|
const wf = workflow('test', 'Test', { registry: testRegistry }).add(
|
|
trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
}).to(setNode),
|
|
);
|
|
|
|
wf.validate();
|
|
|
|
// The Set node should be validated exactly ONCE by the plugin validator
|
|
// If the old inline checks are still running, this would be called twice
|
|
expect(validateCallCounts.get('Set Data')).toBe(1);
|
|
});
|
|
|
|
it('uses global plugin registry when no registry is provided', () => {
|
|
// Import the global registry to add a test validator
|
|
|
|
// Ensure default plugins are registered
|
|
registerDefaultPlugins(pluginRegistry);
|
|
|
|
// Create a workflow WITHOUT explicitly passing a registry
|
|
const wf = workflow('test', 'Test').add(
|
|
trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
}).to(
|
|
node({
|
|
type: '@n8n/n8n-nodes-langchain.agent',
|
|
version: 1.7,
|
|
config: {
|
|
name: 'Agent',
|
|
parameters: {
|
|
promptType: 'define',
|
|
text: 'static prompt without expression', // This should trigger AGENT_STATIC_PROMPT
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
|
|
const result = wf.validate();
|
|
|
|
// The agentValidator plugin should run and detect the static prompt
|
|
expect(result.warnings.some((w) => w.code === 'AGENT_STATIC_PROMPT')).toBe(true);
|
|
});
|
|
|
|
it('inline check methods do not duplicate plugin validation warnings', () => {
|
|
// This test ensures that when a custom registry with plugins is used,
|
|
// the inline check* methods are NOT called (they are replaced by plugins)
|
|
|
|
// Create a registry with the agent validator
|
|
testRegistry.registerValidator(agentValidator);
|
|
|
|
// Create an agent node with issues that both inline and plugin would catch
|
|
const agentNode = node({
|
|
type: '@n8n/n8n-nodes-langchain.agent',
|
|
version: 1.7,
|
|
config: {
|
|
name: 'Agent',
|
|
parameters: {
|
|
promptType: 'define',
|
|
text: 'static prompt', // No expression - triggers warning
|
|
options: {}, // No systemMessage - triggers warning
|
|
},
|
|
},
|
|
});
|
|
|
|
const wf = workflow('test', 'Test', { registry: testRegistry }).add(
|
|
trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
}).to(agentNode),
|
|
);
|
|
|
|
const result = wf.validate();
|
|
|
|
// Count warnings of each type - should be exactly 1, not 2 (no duplicates)
|
|
const staticPromptWarnings = result.warnings.filter((w) => w.code === 'AGENT_STATIC_PROMPT');
|
|
const noSystemMessageWarnings = result.warnings.filter(
|
|
(w) => w.code === 'AGENT_NO_SYSTEM_MESSAGE',
|
|
);
|
|
|
|
// Each warning should appear exactly once (from plugin only, not duplicated)
|
|
expect(staticPromptWarnings.length).toBe(1);
|
|
expect(noSystemMessageWarnings.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('Phase 6.6.5: Verify plugin handlers are used for all composite types', () => {
|
|
it('ifElse builder is handled by global pluginRegistry handler', () => {
|
|
registerDefaultPlugins(pluginRegistry);
|
|
|
|
const findHandlerSpy = jest.spyOn(pluginRegistry, 'findCompositeHandler');
|
|
|
|
const trueBranch = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'True', parameters: {} },
|
|
});
|
|
const composite = ifElse({ version: 2, config: { name: 'If', parameters: {} } }).onTrue!(
|
|
trueBranch,
|
|
).onFalse(null);
|
|
|
|
// Use workflow without custom registry - uses global pluginRegistry
|
|
const wf = workflow('test', 'Test').add(composite);
|
|
|
|
// Verify handler was found (may be called multiple times, find the one that returned a handler)
|
|
expect(findHandlerSpy).toHaveBeenCalled();
|
|
const foundHandler = findHandlerSpy.mock.results.find(
|
|
(r: { value?: { id: string } }) => r.value?.id === 'core:if-else',
|
|
) as { value?: { id: string } } | undefined;
|
|
expect(foundHandler).toBeDefined();
|
|
expect(foundHandler!.value!.id).toBe('core:if-else');
|
|
|
|
// Verify workflow was built correctly
|
|
const json = wf.toJSON();
|
|
expect(json.nodes.map((n) => n.name)).toContain('If');
|
|
expect(json.nodes.map((n) => n.name)).toContain('True');
|
|
|
|
findHandlerSpy.mockRestore();
|
|
});
|
|
|
|
it('switchCase builder is handled by global pluginRegistry handler', () => {
|
|
registerDefaultPlugins(pluginRegistry);
|
|
|
|
const findHandlerSpy = jest.spyOn(pluginRegistry, 'findCompositeHandler');
|
|
|
|
const case0 = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Case 0', parameters: {} },
|
|
});
|
|
// Use the switchCase factory function syntax
|
|
const switchNode = switchCase({
|
|
version: 3,
|
|
config: { name: 'Switch', parameters: {} },
|
|
});
|
|
const composite = switchNode.onCase!(0, case0);
|
|
|
|
const wf = workflow('test', 'Test').add(composite);
|
|
|
|
// Verify handler was found
|
|
const foundHandler = findHandlerSpy.mock.results.find(
|
|
(r: { value?: { id: string } }) => r.value?.id === 'core:switch-case',
|
|
);
|
|
expect(foundHandler).toBeDefined();
|
|
|
|
// Verify workflow was built correctly
|
|
const json = wf.toJSON();
|
|
expect(json.nodes.map((n) => n.name)).toContain('Switch');
|
|
expect(json.nodes.map((n) => n.name)).toContain('Case 0');
|
|
|
|
findHandlerSpy.mockRestore();
|
|
});
|
|
|
|
it('splitInBatches builder is handled by global pluginRegistry handler', () => {
|
|
registerDefaultPlugins(pluginRegistry);
|
|
|
|
const findHandlerSpy = jest.spyOn(pluginRegistry, 'findCompositeHandler');
|
|
|
|
const doneNode = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Done', parameters: {} },
|
|
});
|
|
const sib = splitInBatches({ version: 3 }).onDone(doneNode);
|
|
|
|
const wf = workflow('test', 'Test').add(
|
|
sib as unknown as NodeInstance<string, string, unknown>,
|
|
);
|
|
|
|
// Verify handler was found
|
|
const foundHandler = findHandlerSpy.mock.results.find(
|
|
(r: { value?: { id: string } }) => r.value?.id === 'core:split-in-batches',
|
|
);
|
|
expect(foundHandler).toBeDefined();
|
|
|
|
// Verify workflow was built correctly
|
|
const json = wf.toJSON();
|
|
expect(json.nodes.map((n) => n.name)).toContain('Split In Batches');
|
|
expect(json.nodes.map((n) => n.name)).toContain('Done');
|
|
|
|
findHandlerSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('Phase 9.2: getHeadNodeName() and resolveCompositeHeadName()', () => {
|
|
it('registry.resolveCompositeHeadName returns head node name for IfElseBuilder', () => {
|
|
registerDefaultPlugins(pluginRegistry);
|
|
|
|
const composite = ifElse({ version: 2, config: { name: 'My If', parameters: {} } }).onTrue!(
|
|
null,
|
|
).onFalse(null);
|
|
|
|
const headName = pluginRegistry.resolveCompositeHeadName(composite);
|
|
|
|
expect(headName).toBe('My If');
|
|
});
|
|
|
|
it('registry.resolveCompositeHeadName returns head node name for SwitchCaseBuilder', () => {
|
|
registerDefaultPlugins(pluginRegistry);
|
|
|
|
const composite = switchCase({
|
|
version: 3,
|
|
config: { name: 'My Switch', parameters: {} },
|
|
}).onCase!(0, null);
|
|
|
|
const headName = pluginRegistry.resolveCompositeHeadName(composite);
|
|
|
|
expect(headName).toBe('My Switch');
|
|
});
|
|
|
|
it('registry.resolveCompositeHeadName returns head node name for SplitInBatchesBuilder', () => {
|
|
registerDefaultPlugins(pluginRegistry);
|
|
|
|
const sib = splitInBatches({ version: 3, config: { name: 'My SIB' } });
|
|
|
|
const headName = pluginRegistry.resolveCompositeHeadName(sib);
|
|
|
|
expect(headName).toBe('My SIB');
|
|
});
|
|
|
|
it('registry.resolveCompositeHeadName applies nameMapping when provided', () => {
|
|
registerDefaultPlugins(pluginRegistry);
|
|
|
|
const composite = ifElse({ version: 2, config: { name: 'If', parameters: {} } }).onTrue!(
|
|
null,
|
|
).onFalse(null);
|
|
|
|
// Simulate that the If node was renamed during add
|
|
const nameMapping = new Map<string, string>();
|
|
nameMapping.set(composite.ifNode.id, 'If 1');
|
|
|
|
const headName = pluginRegistry.resolveCompositeHeadName(composite, nameMapping);
|
|
|
|
expect(headName).toBe('If 1');
|
|
});
|
|
|
|
it('registry.resolveCompositeHeadName returns undefined for non-composites', () => {
|
|
registerDefaultPlugins(pluginRegistry);
|
|
|
|
const regularNode = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Set', parameters: {} },
|
|
});
|
|
|
|
const headName = pluginRegistry.resolveCompositeHeadName(regularNode);
|
|
|
|
expect(headName).toBeUndefined();
|
|
});
|
|
|
|
it('handler.getHeadNodeName is called by resolveCompositeHeadName', () => {
|
|
const mockGetHeadNodeName = jest.fn().mockReturnValue('Custom Head');
|
|
const customHandler: CompositeHandlerPlugin<{ custom: true }> = {
|
|
id: 'test:custom',
|
|
name: 'Custom Handler',
|
|
canHandle: (input): input is { custom: true } =>
|
|
input !== null && typeof input === 'object' && 'custom' in input,
|
|
addNodes: () => 'Custom Head',
|
|
getHeadNodeName: mockGetHeadNodeName,
|
|
};
|
|
|
|
const customRegistry = new PluginRegistry();
|
|
customRegistry.registerCompositeHandler(customHandler);
|
|
|
|
const headName = customRegistry.resolveCompositeHeadName({ custom: true });
|
|
|
|
expect(mockGetHeadNodeName).toHaveBeenCalledWith({ custom: true });
|
|
expect(headName).toBe('Custom Head');
|
|
});
|
|
|
|
it('handler without getHeadNodeName returns undefined from resolveCompositeHeadName', () => {
|
|
const handlerWithoutGetHead: CompositeHandlerPlugin<{ noHead: true }> = {
|
|
id: 'test:no-head',
|
|
name: 'Handler Without getHeadNodeName',
|
|
canHandle: (input): input is { noHead: true } =>
|
|
input !== null && typeof input === 'object' && 'noHead' in input,
|
|
addNodes: () => 'Some Node',
|
|
// Note: no getHeadNodeName method
|
|
};
|
|
|
|
const customRegistry = new PluginRegistry();
|
|
customRegistry.registerCompositeHandler(handlerWithoutGetHead);
|
|
|
|
const headName = customRegistry.resolveCompositeHeadName({ noHead: true });
|
|
|
|
expect(headName).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Phase 10.2: disconnectedNodeValidator as full plugin', () => {
|
|
it('validateWorkflow directly detects disconnected nodes', () => {
|
|
// Build a minimal PluginContext manually to test the validator directly
|
|
const triggerNode = trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
});
|
|
const connectedNode = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Connected', parameters: {} },
|
|
});
|
|
const disconnectedNode = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Disconnected', parameters: {} },
|
|
});
|
|
|
|
// Build workflow to verify the test setup is correct
|
|
workflow('test', 'Test').add(triggerNode.to(connectedNode)).add(disconnectedNode);
|
|
|
|
// Create a manual context to test the validator directly
|
|
// We need to create GraphNode objects that match what the validator expects
|
|
const nodesMap = new Map<
|
|
string,
|
|
{
|
|
instance: { name: string; type: string };
|
|
connections: Map<string, Map<number, Array<{ node: string }>>>;
|
|
}
|
|
>();
|
|
|
|
// Trigger node - no incoming connections needed
|
|
nodesMap.set('Start', {
|
|
instance: { name: 'Start', type: 'n8n-nodes-base.manualTrigger' },
|
|
connections: new Map([['main', new Map([[0, [{ node: 'Connected' }]]])]]),
|
|
});
|
|
|
|
// Connected node - has incoming connection from Start
|
|
nodesMap.set('Connected', {
|
|
instance: { name: 'Connected', type: 'n8n-nodes-base.set' },
|
|
connections: new Map(),
|
|
});
|
|
|
|
// Disconnected node - no incoming connections
|
|
nodesMap.set('Disconnected', {
|
|
instance: { name: 'Disconnected', type: 'n8n-nodes-base.set' },
|
|
connections: new Map(),
|
|
});
|
|
|
|
const ctx: PluginContext = {
|
|
nodes: nodesMap as unknown as ReadonlyMap<string, GraphNode>,
|
|
workflowId: 'test',
|
|
workflowName: 'Test',
|
|
settings: {},
|
|
};
|
|
|
|
// Call validateWorkflow directly on the plugin
|
|
const issues = disconnectedNodeValidator.validateWorkflow!(ctx);
|
|
|
|
// Should detect the disconnected node
|
|
expect(issues.length).toBeGreaterThan(0);
|
|
expect(issues.some((i: { code: string }) => i.code === 'DISCONNECTED_NODE')).toBe(true);
|
|
expect(issues.some((i) => i.nodeName === 'Disconnected')).toBe(true);
|
|
});
|
|
|
|
it('validateWorkflow respects allowDisconnectedNodes in context', () => {
|
|
// Create a manual context with a disconnected node and allowDisconnectedNodes option
|
|
const nodesMap = new Map<
|
|
string,
|
|
{
|
|
instance: { name: string; type: string };
|
|
connections: Map<string, Map<number, Array<{ node: string }>>>;
|
|
}
|
|
>();
|
|
|
|
nodesMap.set('Start', {
|
|
instance: { name: 'Start', type: 'n8n-nodes-base.manualTrigger' },
|
|
connections: new Map(),
|
|
});
|
|
|
|
nodesMap.set('Disconnected', {
|
|
instance: { name: 'Disconnected', type: 'n8n-nodes-base.set' },
|
|
connections: new Map(),
|
|
});
|
|
|
|
const ctx: PluginContext & { validationOptions?: { allowDisconnectedNodes?: boolean } } = {
|
|
nodes: nodesMap as unknown as ReadonlyMap<string, GraphNode>,
|
|
workflowId: 'test',
|
|
workflowName: 'Test',
|
|
settings: {},
|
|
validationOptions: { allowDisconnectedNodes: true },
|
|
};
|
|
|
|
// Call validateWorkflow directly on the plugin
|
|
const issues = disconnectedNodeValidator.validateWorkflow!(ctx);
|
|
|
|
// Should NOT detect disconnected node when allowDisconnectedNodes is true
|
|
expect(issues.some((i: { code: string }) => i.code === 'DISCONNECTED_NODE')).toBe(false);
|
|
});
|
|
|
|
it('integration: workflow.validate() uses disconnectedNodeValidator plugin', () => {
|
|
const customRegistry = new PluginRegistry();
|
|
customRegistry.registerValidator(disconnectedNodeValidator);
|
|
|
|
// Create workflow with a disconnected node
|
|
const triggerNode = trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
});
|
|
const disconnectedNode = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Disconnected', parameters: {} },
|
|
});
|
|
|
|
const wf = workflow('test', 'Test', { registry: customRegistry })
|
|
.add(triggerNode)
|
|
.add(disconnectedNode);
|
|
|
|
const result = wf.validate();
|
|
|
|
// Should detect the disconnected node
|
|
expect(result.warnings.some((w) => w.code === 'DISCONNECTED_NODE')).toBe(true);
|
|
});
|
|
|
|
it('skips trigger nodes (they do not need incoming connections)', () => {
|
|
const nodesMap = new Map<
|
|
string,
|
|
{
|
|
instance: { name: string; type: string };
|
|
connections: Map<string, Map<number, Array<{ node: string }>>>;
|
|
}
|
|
>();
|
|
|
|
// Only a trigger node
|
|
nodesMap.set('Start', {
|
|
instance: { name: 'Start', type: 'n8n-nodes-base.manualTrigger' },
|
|
connections: new Map(),
|
|
});
|
|
|
|
const ctx: PluginContext = {
|
|
nodes: nodesMap as unknown as ReadonlyMap<string, GraphNode>,
|
|
workflowId: 'test',
|
|
workflowName: 'Test',
|
|
settings: {},
|
|
};
|
|
|
|
const issues = disconnectedNodeValidator.validateWorkflow!(ctx);
|
|
|
|
// Should NOT warn about trigger node being disconnected
|
|
expect(issues.some((i: { code: string }) => i.code === 'DISCONNECTED_NODE')).toBe(false);
|
|
});
|
|
|
|
it('skips sticky notes (they do not participate in data flow)', () => {
|
|
const nodesMap = new Map<
|
|
string,
|
|
{
|
|
instance: { name: string; type: string };
|
|
connections: Map<string, Map<number, Array<{ node: string }>>>;
|
|
}
|
|
>();
|
|
|
|
nodesMap.set('Start', {
|
|
instance: { name: 'Start', type: 'n8n-nodes-base.manualTrigger' },
|
|
connections: new Map(),
|
|
});
|
|
|
|
nodesMap.set('Note', {
|
|
instance: { name: 'Note', type: 'n8n-nodes-base.stickyNote' },
|
|
connections: new Map(),
|
|
});
|
|
|
|
const ctx: PluginContext = {
|
|
nodes: nodesMap as unknown as ReadonlyMap<string, GraphNode>,
|
|
workflowId: 'test',
|
|
workflowName: 'Test',
|
|
settings: {},
|
|
};
|
|
|
|
const issues = disconnectedNodeValidator.validateWorkflow!(ctx);
|
|
|
|
// Should NOT warn about sticky note being disconnected
|
|
expect(issues.some((i: { code: string }) => i.code === 'DISCONNECTED_NODE')).toBe(false);
|
|
});
|
|
|
|
it('skips subnodes connected via AI connections', () => {
|
|
const nodesMap = new Map<
|
|
string,
|
|
{
|
|
instance: { name: string; type: string };
|
|
connections: Map<string, Map<number, Array<{ node: string }>>>;
|
|
}
|
|
>();
|
|
|
|
// Agent node
|
|
nodesMap.set('Agent', {
|
|
instance: { name: 'Agent', type: '@n8n/n8n-nodes-langchain.agent' },
|
|
connections: new Map(),
|
|
});
|
|
|
|
// Language model connected to agent via ai_languageModel
|
|
nodesMap.set('OpenAI', {
|
|
instance: { name: 'OpenAI', type: '@n8n/n8n-nodes-langchain.lmOpenAi' },
|
|
connections: new Map([['ai_languageModel', new Map([[0, [{ node: 'Agent' }]]])]]),
|
|
});
|
|
|
|
const ctx: PluginContext = {
|
|
nodes: nodesMap as unknown as ReadonlyMap<string, GraphNode>,
|
|
workflowId: 'test',
|
|
workflowName: 'Test',
|
|
settings: {},
|
|
};
|
|
|
|
const issues = disconnectedNodeValidator.validateWorkflow!(ctx);
|
|
|
|
// Should NOT warn about subnodes connected via AI connections
|
|
// The OpenAI node is a subnode connected via ai_languageModel
|
|
expect(issues.some((i) => i.nodeName === 'OpenAI')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Phase 11.1: noNodesValidator plugin', () => {
|
|
it('validateWorkflow returns error when workflow has no nodes', () => {
|
|
// Empty nodes map
|
|
const ctx: PluginContext = {
|
|
nodes: new Map(),
|
|
workflowId: 'test',
|
|
workflowName: 'Test',
|
|
settings: {},
|
|
};
|
|
|
|
const issues = noNodesValidator.validateWorkflow!(ctx);
|
|
|
|
expect(issues.length).toBe(1);
|
|
expect(issues[0].code).toBe('NO_NODES');
|
|
expect(issues[0].severity).toBe('error');
|
|
});
|
|
|
|
it('validateWorkflow returns empty array when workflow has nodes', () => {
|
|
// Non-empty nodes map
|
|
const nodesMap = new Map();
|
|
nodesMap.set('Start', {
|
|
instance: { name: 'Start', type: 'n8n-nodes-base.manualTrigger' },
|
|
connections: new Map(),
|
|
});
|
|
|
|
const ctx: PluginContext = {
|
|
nodes: nodesMap as unknown as ReadonlyMap<string, GraphNode>,
|
|
workflowId: 'test',
|
|
workflowName: 'Test',
|
|
settings: {},
|
|
};
|
|
|
|
const issues = noNodesValidator.validateWorkflow!(ctx);
|
|
|
|
expect(issues.length).toBe(0);
|
|
});
|
|
|
|
it('integration: workflow.validate() reports NO_NODES error for empty workflow', () => {
|
|
const wf = workflow('test', 'Empty Workflow');
|
|
|
|
const result = wf.validate();
|
|
|
|
expect(result.valid).toBe(false);
|
|
expect(result.errors.some((e) => e.code === 'NO_NODES')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Phase 11.2: missingTriggerValidator plugin', () => {
|
|
it('validateWorkflow returns warning when no trigger node exists', () => {
|
|
// Nodes map with only non-trigger nodes
|
|
const nodesMap = new Map();
|
|
nodesMap.set('Set', {
|
|
instance: { name: 'Set', type: 'n8n-nodes-base.set' },
|
|
connections: new Map(),
|
|
});
|
|
|
|
const ctx: PluginContext = {
|
|
nodes: nodesMap as unknown as ReadonlyMap<string, GraphNode>,
|
|
workflowId: 'test',
|
|
workflowName: 'Test',
|
|
settings: {},
|
|
};
|
|
|
|
const issues = missingTriggerValidator.validateWorkflow!(ctx);
|
|
|
|
expect(issues.length).toBe(1);
|
|
expect(issues[0].code).toBe('MISSING_TRIGGER');
|
|
expect(issues[0].severity).toBe('warning');
|
|
});
|
|
|
|
it('validateWorkflow returns empty array when trigger node exists', () => {
|
|
const nodesMap = new Map();
|
|
nodesMap.set('Start', {
|
|
instance: { name: 'Start', type: 'n8n-nodes-base.manualTrigger' },
|
|
connections: new Map(),
|
|
});
|
|
|
|
const ctx: PluginContext = {
|
|
nodes: nodesMap as unknown as ReadonlyMap<string, GraphNode>,
|
|
workflowId: 'test',
|
|
workflowName: 'Test',
|
|
settings: {},
|
|
};
|
|
|
|
const issues = missingTriggerValidator.validateWorkflow!(ctx);
|
|
|
|
expect(issues.length).toBe(0);
|
|
});
|
|
|
|
it('validateWorkflow respects allowNoTrigger option', () => {
|
|
const nodesMap = new Map();
|
|
nodesMap.set('Set', {
|
|
instance: { name: 'Set', type: 'n8n-nodes-base.set' },
|
|
connections: new Map(),
|
|
});
|
|
|
|
const ctx: PluginContext = {
|
|
nodes: nodesMap as unknown as ReadonlyMap<string, GraphNode>,
|
|
workflowId: 'test',
|
|
workflowName: 'Test',
|
|
settings: {},
|
|
validationOptions: { allowNoTrigger: true },
|
|
};
|
|
|
|
const issues = missingTriggerValidator.validateWorkflow!(ctx);
|
|
|
|
expect(issues.length).toBe(0);
|
|
});
|
|
|
|
it('integration: workflow.validate() reports MISSING_TRIGGER warning', () => {
|
|
const setNode = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Set', parameters: {} },
|
|
});
|
|
|
|
const wf = workflow('test', 'No Trigger').add(setNode);
|
|
|
|
const result = wf.validate();
|
|
|
|
expect(result.warnings.some((w) => w.code === 'MISSING_TRIGGER')).toBe(true);
|
|
});
|
|
|
|
it('integration: workflow.validate() skips MISSING_TRIGGER when allowNoTrigger is true', () => {
|
|
const setNode = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Set', parameters: {} },
|
|
});
|
|
|
|
const wf = workflow('test', 'No Trigger').add(setNode);
|
|
|
|
const result = wf.validate({ allowNoTrigger: true });
|
|
|
|
expect(result.warnings.some((w) => w.code === 'MISSING_TRIGGER')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('Phase 10.3: JSON serializer extraction', () => {
|
|
it('jsonSerializer.serialize produces basic workflow structure', () => {
|
|
// Create a minimal SerializerContext
|
|
const ctx = {
|
|
nodes: new Map(),
|
|
workflowId: 'wf-123',
|
|
workflowName: 'Test Workflow',
|
|
settings: { timezone: 'UTC' },
|
|
pinData: { Node1: [{ item: 'data' }] },
|
|
resolveTargetNodeName: () => undefined,
|
|
};
|
|
|
|
const result = jsonSerializer.serialize(ctx);
|
|
|
|
expect(result.id).toBe('wf-123');
|
|
expect(result.name).toBe('Test Workflow');
|
|
expect(result.settings).toEqual({ timezone: 'UTC' });
|
|
expect(result.pinData).toEqual({ Node1: [{ item: 'data' }] });
|
|
});
|
|
|
|
it('workflow.toFormat("json") uses jsonSerializer when registered', () => {
|
|
const customRegistry = new PluginRegistry();
|
|
customRegistry.registerSerializer(jsonSerializer);
|
|
|
|
const wf = workflow('wf-456', 'My Workflow', { registry: customRegistry }).add(
|
|
trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
}),
|
|
);
|
|
|
|
// toFormat delegates to the serializer
|
|
const result = wf.toFormat<WorkflowJSON>('json');
|
|
|
|
// Note: The current stub serializer only returns basic structure
|
|
// without nodes/connections
|
|
expect(result.id).toBe('wf-456');
|
|
expect(result.name).toBe('My Workflow');
|
|
});
|
|
|
|
it('workflow.toJSON() returns full serialization with nodes and connections', () => {
|
|
const triggerNode = trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
});
|
|
const setNode = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Set Data', parameters: { values: [] } },
|
|
});
|
|
|
|
const wf = workflow('wf-789', 'Full Test').add(triggerNode.to(setNode));
|
|
|
|
const json = wf.toJSON();
|
|
|
|
// toJSON should produce full serialization
|
|
expect(json.id).toBe('wf-789');
|
|
expect(json.name).toBe('Full Test');
|
|
expect(json.nodes).toHaveLength(2);
|
|
expect(json.nodes.map((n) => n.name)).toContain('Start');
|
|
expect(json.nodes.map((n) => n.name)).toContain('Set Data');
|
|
// Connections should be present
|
|
expect(json.connections['Start']).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('Phase 9.3: collectPinData() on composite handlers', () => {
|
|
it('ifElseHandler.collectPinData collects pin data from IF node and branches', () => {
|
|
const collectedNodes: string[] = [];
|
|
const collector = (node: NodeInstance<string, string, unknown>) => {
|
|
collectedNodes.push(node.name);
|
|
};
|
|
|
|
// Create IfElseBuilder with branches that have pinData
|
|
const trueNode = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'True Node', parameters: {}, pinData: [{ item: 'true' }] },
|
|
});
|
|
const falseNode = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'False Node', parameters: {}, pinData: [{ item: 'false' }] },
|
|
});
|
|
|
|
const composite = ifElse({
|
|
version: 2,
|
|
config: { name: 'If', parameters: {}, pinData: [{ item: 'if' }] },
|
|
}).onTrue!(trueNode).onFalse(falseNode);
|
|
|
|
ifElseHandler.collectPinData!(composite, collector);
|
|
|
|
expect(collectedNodes).toContain('If');
|
|
expect(collectedNodes).toContain('True Node');
|
|
expect(collectedNodes).toContain('False Node');
|
|
});
|
|
|
|
it('switchCaseHandler.collectPinData collects pin data from switch node and cases', () => {
|
|
const collectedNodes: string[] = [];
|
|
const collector = (node: NodeInstance<string, string, unknown>) => {
|
|
collectedNodes.push(node.name);
|
|
};
|
|
|
|
const case0 = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Case 0', parameters: {}, pinData: [{ item: 'case0' }] },
|
|
});
|
|
const case1 = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Case 1', parameters: {}, pinData: [{ item: 'case1' }] },
|
|
});
|
|
|
|
const composite = switchCase({
|
|
version: 3,
|
|
config: { name: 'Switch', parameters: {}, pinData: [{ item: 'switch' }] },
|
|
}).onCase!(0, case0).onCase(1, case1);
|
|
|
|
switchCaseHandler.collectPinData!(composite, collector);
|
|
|
|
expect(collectedNodes).toContain('Switch');
|
|
expect(collectedNodes).toContain('Case 0');
|
|
expect(collectedNodes).toContain('Case 1');
|
|
});
|
|
|
|
it('splitInBatchesHandler.collectPinData collects pin data from SIB node and targets', () => {
|
|
const collectedNodes: string[] = [];
|
|
const collector = (node: NodeInstance<string, string, unknown>) => {
|
|
collectedNodes.push(node.name);
|
|
};
|
|
|
|
const doneNode = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Done', parameters: {}, pinData: [{ item: 'done' }] },
|
|
});
|
|
const eachNode = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Each', parameters: {}, pinData: [{ item: 'each' }] },
|
|
});
|
|
|
|
const sib = splitInBatches({
|
|
version: 3,
|
|
config: { name: 'SIB', pinData: [{ item: 'sib' }] },
|
|
})
|
|
.onDone(doneNode)
|
|
.onEachBatch(eachNode);
|
|
|
|
// The handler's SplitInBatchesBuilderShape is internal, but the builder implements it at runtime
|
|
(
|
|
splitInBatchesHandler.collectPinData as (
|
|
input: unknown,
|
|
collector: (node: NodeInstance<string, string, unknown>) => void,
|
|
) => void
|
|
)(sib, collector);
|
|
|
|
expect(collectedNodes).toContain('SIB');
|
|
// Note: done/each nodes may not be collected by the handler as they're added via addBranchToGraph
|
|
});
|
|
|
|
it('handlers without collectPinData are gracefully skipped', () => {
|
|
const handlerWithoutCollectPinData: CompositeHandlerPlugin<{ noPinData: true }> = {
|
|
id: 'test:no-pin-data',
|
|
name: 'Handler Without collectPinData',
|
|
canHandle: (input): input is { noPinData: true } =>
|
|
input !== null && typeof input === 'object' && 'noPinData' in input,
|
|
addNodes: () => 'Some Node',
|
|
// Note: no collectPinData method
|
|
};
|
|
|
|
// Should not throw when handler doesn't have collectPinData
|
|
expect(handlerWithoutCollectPinData.collectPinData).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('Phase 13: maxNodesValidator plugin', () => {
|
|
it('validateWorkflow returns empty array when no nodeTypesProvider', () => {
|
|
const nodesMap = new Map();
|
|
nodesMap.set('Set 1', {
|
|
instance: { name: 'Set', type: 'n8n-nodes-base.set', version: 3.4 },
|
|
connections: new Map(),
|
|
});
|
|
nodesMap.set('Set 2', {
|
|
instance: { name: 'Set', type: 'n8n-nodes-base.set', version: 3.4 },
|
|
connections: new Map(),
|
|
});
|
|
|
|
const ctx: PluginContext = {
|
|
nodes: nodesMap as unknown as ReadonlyMap<string, GraphNode>,
|
|
workflowId: 'test',
|
|
workflowName: 'Test',
|
|
settings: {},
|
|
// No nodeTypesProvider
|
|
};
|
|
|
|
const issues = maxNodesValidator.validateWorkflow!(ctx);
|
|
|
|
expect(issues.length).toBe(0);
|
|
});
|
|
|
|
it('validateWorkflow returns empty array when count <= maxNodes', () => {
|
|
const nodesMap = new Map();
|
|
nodesMap.set('Set 1', {
|
|
instance: { name: 'Set', type: 'n8n-nodes-base.set', version: 3.4 },
|
|
connections: new Map(),
|
|
});
|
|
|
|
const mockProvider = {
|
|
getByNameAndVersion: () => ({
|
|
description: { maxNodes: 2, displayName: 'Set' },
|
|
}),
|
|
};
|
|
|
|
const ctx: PluginContext = {
|
|
nodes: nodesMap as unknown as ReadonlyMap<string, GraphNode>,
|
|
workflowId: 'test',
|
|
workflowName: 'Test',
|
|
settings: {},
|
|
validationOptions: { nodeTypesProvider: mockProvider },
|
|
};
|
|
|
|
const issues = maxNodesValidator.validateWorkflow!(ctx);
|
|
|
|
expect(issues.length).toBe(0);
|
|
});
|
|
|
|
it('validateWorkflow returns error when count > maxNodes', () => {
|
|
const nodesMap = new Map();
|
|
nodesMap.set('Set 1', {
|
|
instance: { name: 'Set', type: 'n8n-nodes-base.set', version: 3.4 },
|
|
connections: new Map(),
|
|
});
|
|
nodesMap.set('Set 2', {
|
|
instance: { name: 'Set', type: 'n8n-nodes-base.set', version: 3.4 },
|
|
connections: new Map(),
|
|
});
|
|
nodesMap.set('Set 3', {
|
|
instance: { name: 'Set', type: 'n8n-nodes-base.set', version: 3.4 },
|
|
connections: new Map(),
|
|
});
|
|
|
|
const mockProvider = {
|
|
getByNameAndVersion: () => ({
|
|
description: { maxNodes: 2, displayName: 'Set' },
|
|
}),
|
|
};
|
|
|
|
const ctx: PluginContext = {
|
|
nodes: nodesMap as unknown as ReadonlyMap<string, GraphNode>,
|
|
workflowId: 'test',
|
|
workflowName: 'Test',
|
|
settings: {},
|
|
validationOptions: { nodeTypesProvider: mockProvider },
|
|
};
|
|
|
|
const issues = maxNodesValidator.validateWorkflow!(ctx);
|
|
|
|
expect(issues.length).toBe(1);
|
|
expect(issues[0].code).toBe('MAX_NODES_EXCEEDED');
|
|
expect(issues[0].severity).toBe('error');
|
|
expect(issues[0].message).toContain('3');
|
|
expect(issues[0].message).toContain('Set');
|
|
expect(issues[0].message).toContain('2');
|
|
});
|
|
|
|
it('validateWorkflow skips types with no maxNodes defined', () => {
|
|
const nodesMap = new Map();
|
|
nodesMap.set('Set 1', {
|
|
instance: { name: 'Set', type: 'n8n-nodes-base.set', version: 3.4 },
|
|
connections: new Map(),
|
|
});
|
|
nodesMap.set('Set 2', {
|
|
instance: { name: 'Set', type: 'n8n-nodes-base.set', version: 3.4 },
|
|
connections: new Map(),
|
|
});
|
|
|
|
const mockProvider = {
|
|
getByNameAndVersion: () => ({
|
|
description: { displayName: 'Set' }, // No maxNodes
|
|
}),
|
|
};
|
|
|
|
const ctx: PluginContext = {
|
|
nodes: nodesMap as unknown as ReadonlyMap<string, GraphNode>,
|
|
workflowId: 'test',
|
|
workflowName: 'Test',
|
|
settings: {},
|
|
validationOptions: { nodeTypesProvider: mockProvider },
|
|
};
|
|
|
|
const issues = maxNodesValidator.validateWorkflow!(ctx);
|
|
|
|
expect(issues.length).toBe(0);
|
|
});
|
|
|
|
it('integration: workflow.validate() reports MAX_NODES_EXCEEDED error', () => {
|
|
const setNode1 = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Set 1', parameters: {} },
|
|
});
|
|
const setNode2 = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Set 2', parameters: {} },
|
|
});
|
|
const setNode3 = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Set 3', parameters: {} },
|
|
});
|
|
|
|
const wf = workflow('test', 'Test')
|
|
.add(
|
|
trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
}),
|
|
)
|
|
.add(setNode1)
|
|
.add(setNode2)
|
|
.add(setNode3);
|
|
|
|
const mockProvider = {
|
|
getByNameAndVersion: (type: string) => {
|
|
if (type === 'n8n-nodes-base.set') {
|
|
return { description: { maxNodes: 2, displayName: 'Set' } };
|
|
}
|
|
return undefined;
|
|
},
|
|
};
|
|
|
|
const result = wf.validate({ nodeTypesProvider: mockProvider as never });
|
|
|
|
expect(result.errors.some((e) => e.code === 'MAX_NODES_EXCEEDED')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Phase 18: handleFanOut branching node detection', () => {
|
|
it('treats multi-output nodes (like Text Classifier) as branching nodes with array syntax', () => {
|
|
// Text Classifier is a multi-output node that should work like IF/Switch
|
|
// when using .to([branch0, branch1, branch2]) syntax.
|
|
// Currently, only IF, Switch, and SplitInBatches are hardcoded as branching nodes.
|
|
const textClassifier = node({
|
|
type: '@n8n/n8n-nodes-langchain.textClassifier',
|
|
version: 1,
|
|
config: {
|
|
name: 'Classify',
|
|
parameters: {
|
|
categories: {
|
|
categories: [
|
|
{ category: 'Billing', description: 'billing issues' },
|
|
{ category: 'Support', description: 'support requests' },
|
|
{ category: 'Sales', description: 'sales inquiries' },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const billingHandler = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Handle Billing', parameters: {} },
|
|
});
|
|
|
|
const supportHandler = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Handle Support', parameters: {} },
|
|
});
|
|
|
|
const salesHandler = node({
|
|
type: 'n8n-nodes-base.set',
|
|
version: 3.4,
|
|
config: { name: 'Handle Sales', parameters: {} },
|
|
});
|
|
|
|
// Using array syntax should treat textClassifier as a branching node
|
|
// Each element should connect to a different output index
|
|
const wf = workflow('test', 'Text Classifier Branching')
|
|
.add(
|
|
trigger({
|
|
type: 'n8n-nodes-base.manualTrigger',
|
|
version: 1,
|
|
config: { name: 'Start' },
|
|
}),
|
|
)
|
|
.to(textClassifier)
|
|
.to([billingHandler, supportHandler, salesHandler]);
|
|
|
|
const json = wf.toJSON();
|
|
|
|
// Check connections from Classify node
|
|
const classifyConns = json.connections['Classify'];
|
|
expect(classifyConns).toBeDefined();
|
|
expect(classifyConns.main).toBeDefined();
|
|
|
|
// Each handler should be connected to a DIFFERENT output index (branching behavior)
|
|
// Output 0 -> Handle Billing
|
|
// Output 1 -> Handle Support
|
|
// Output 2 -> Handle Sales
|
|
expect(classifyConns.main[0]).toEqual([{ node: 'Handle Billing', type: 'main', index: 0 }]);
|
|
expect(classifyConns.main[1]).toEqual([{ node: 'Handle Support', type: 'main', index: 0 }]);
|
|
expect(classifyConns.main[2]).toEqual([{ node: 'Handle Sales', type: 'main', index: 0 }]);
|
|
});
|
|
});
|
|
});
|