mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-20 01:13:07 +00:00
Compare commits
5 Commits
fix/issue-
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d50cf93f0 | ||
|
|
de9f222cfe | ||
|
|
da593400d2 | ||
|
|
126d09c66b | ||
|
|
4f81962953 |
61
CHANGELOG.md
61
CHANGELOG.md
@@ -5,6 +5,67 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [2.15.6] - 2025-10-05
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Issue #269: Missing addNode Examples** - Added comprehensive examples for addNode operation in MCP tool documentation
|
||||||
|
- Problem: Claude AI didn't know how to use addNode operation correctly due to zero examples in documentation
|
||||||
|
- Solution: Added 4 progressive examples to `n8n_update_partial_workflow` tool documentation:
|
||||||
|
1. Basic addNode (minimal configuration)
|
||||||
|
2. Complete addNode (full parameters including typeVersion)
|
||||||
|
3. addNode + addConnection combo (most common pattern)
|
||||||
|
4. Batch operation (multiple nodes + connections)
|
||||||
|
- Impact: AI assistants can now correctly use addNode without errors or trial-and-error
|
||||||
|
|
||||||
|
- **Issue #270: Apostrophes in Node Names** - Fixed workflow diff operations failing when node names contain special characters
|
||||||
|
- Root Cause: `findNode()` method used exact string matching without normalization, causing escaped vs unescaped character mismatches
|
||||||
|
- Example: Default Manual Trigger node name "When clicking 'Execute workflow'" failed when JSON-RPC sent escaped version "When clicking \\'Execute workflow\\'"
|
||||||
|
- Solution: Added `normalizeNodeName()` helper that unescapes special characters (quotes, backslashes) and normalizes whitespace
|
||||||
|
- Affected Operations: 8 operations fixed - addConnection, removeConnection, updateConnection, removeNode, updateNode, moveNode, enableNode, disableNode
|
||||||
|
- Error Messages: Enhanced all validation methods with `formatNodeNotFoundError()` helper showing available nodes and suggesting node IDs for special characters
|
||||||
|
- Duplicate Prevention: Fixed `validateAddNode()` to use normalization when checking for duplicate node names
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **WorkflowDiffEngine String Normalization** - Enhanced to handle edge cases from code review
|
||||||
|
- Regex Processing Order: Fixed critical bug - now processes backslashes BEFORE quotes (prevents multiply-escaped character failures)
|
||||||
|
- Whitespace Handling: Comprehensive normalization of tabs, newlines, and mixed whitespace (prevents collision edge cases)
|
||||||
|
- Documentation: Added detailed JSDoc warnings about normalization collision risks with examples
|
||||||
|
- Best Practice: Documentation recommends using node IDs over names for special characters
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- **Normalization Algorithm**: 4-step process
|
||||||
|
1. Trim leading/trailing whitespace
|
||||||
|
2. Unescape backslashes (MUST be first!)
|
||||||
|
3. Unescape single and double quotes
|
||||||
|
4. Normalize all whitespace to single spaces
|
||||||
|
- **Error Message Format**: Now shows node IDs (first 8 chars) and suggests using IDs for special characters
|
||||||
|
- **Collision Prevention**: Duplicate checking uses same normalization to prevent subtle bugs
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
- Unit tests: 120/120 passing (up from 116)
|
||||||
|
- New test scenarios:
|
||||||
|
- Tabs in node names
|
||||||
|
- Newlines in node names
|
||||||
|
- Mixed whitespace (tabs + newlines + spaces)
|
||||||
|
- Escaped vs unescaped matching (core Issue #270 scenario)
|
||||||
|
- Coverage: 90.11% statements (up from 90.05%)
|
||||||
|
|
||||||
|
### Code Review
|
||||||
|
- All 6 MUST FIX and SHOULD FIX recommendations implemented:
|
||||||
|
- ✅ Fixed regex processing order (critical bug)
|
||||||
|
- ✅ Added comprehensive whitespace tests
|
||||||
|
- ✅ Fixed duplicate checking normalization
|
||||||
|
- ✅ Enhanced all 6 validation method error messages
|
||||||
|
- ✅ Added comprehensive JSDoc documentation
|
||||||
|
- ✅ Added escaped vs unescaped test case
|
||||||
|
- Final review: APPROVED FOR MERGE (production-ready)
|
||||||
|
|
||||||
|
### Impact
|
||||||
|
- **Workflow Operations**: All 8 affected operations now handle special characters correctly
|
||||||
|
- **User Experience**: Clear error messages with actionable suggestions
|
||||||
|
- **Reliability**: Comprehensive normalization prevents subtle bugs
|
||||||
|
- **Documentation**: Tool documentation updated to reflect fix (v2.15.6+)
|
||||||
|
|
||||||
## [2.15.5] - 2025-10-04
|
## [2.15.5] - 2025-10-04
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.15.5",
|
"version": "2.15.6",
|
||||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ Add **ignoreErrors: true** to removeConnection operations to prevent failures wh
|
|||||||
'continueOnError breaks atomic guarantees - use with caution',
|
'continueOnError breaks atomic guarantees - use with caution',
|
||||||
'Order matters for dependent operations (e.g., must add node before connecting to it)',
|
'Order matters for dependent operations (e.g., must add node before connecting to it)',
|
||||||
'Node references accept ID or name, but name must be unique',
|
'Node references accept ID or name, but name must be unique',
|
||||||
|
'Node names with special characters (apostrophes, quotes) work correctly since v2.15.6 (Issue #270 fixed)',
|
||||||
|
'For best compatibility, prefer node IDs over names when dealing with special characters',
|
||||||
'Use "updates" property for updateNode operations: {type: "updateNode", updates: {...}}',
|
'Use "updates" property for updateNode operations: {type: "updateNode", updates: {...}}',
|
||||||
'cleanStaleConnections removes ALL broken connections - cannot be selective',
|
'cleanStaleConnections removes ALL broken connections - cannot be selective',
|
||||||
'replaceConnections overwrites entire connections object - all previous connections lost'
|
'replaceConnections overwrites entire connections object - all previous connections lost'
|
||||||
|
|||||||
@@ -295,10 +295,14 @@ export class WorkflowDiffEngine {
|
|||||||
// Node operation validators
|
// Node operation validators
|
||||||
private validateAddNode(workflow: Workflow, operation: AddNodeOperation): string | null {
|
private validateAddNode(workflow: Workflow, operation: AddNodeOperation): string | null {
|
||||||
const { node } = operation;
|
const { node } = operation;
|
||||||
|
|
||||||
// Check if node with same name already exists
|
// Check if node with same name already exists (use normalization to prevent collisions)
|
||||||
if (workflow.nodes.some(n => n.name === node.name)) {
|
const normalizedNewName = this.normalizeNodeName(node.name);
|
||||||
return `Node with name "${node.name}" already exists`;
|
const duplicate = workflow.nodes.find(n =>
|
||||||
|
this.normalizeNodeName(n.name) === normalizedNewName
|
||||||
|
);
|
||||||
|
if (duplicate) {
|
||||||
|
return `Node with name "${node.name}" already exists (normalized name matches existing node "${duplicate.name}")`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate node type format
|
// Validate node type format
|
||||||
@@ -316,7 +320,7 @@ export class WorkflowDiffEngine {
|
|||||||
private validateRemoveNode(workflow: Workflow, operation: RemoveNodeOperation): string | null {
|
private validateRemoveNode(workflow: Workflow, operation: RemoveNodeOperation): string | null {
|
||||||
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
|
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return `Node not found: ${operation.nodeId || operation.nodeName}`;
|
return this.formatNodeNotFoundError(workflow, operation.nodeId || operation.nodeName || '', 'removeNode');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if node has connections that would be broken
|
// Check if node has connections that would be broken
|
||||||
@@ -339,7 +343,7 @@ export class WorkflowDiffEngine {
|
|||||||
private validateUpdateNode(workflow: Workflow, operation: UpdateNodeOperation): string | null {
|
private validateUpdateNode(workflow: Workflow, operation: UpdateNodeOperation): string | null {
|
||||||
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
|
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return `Node not found: ${operation.nodeId || operation.nodeName}`;
|
return this.formatNodeNotFoundError(workflow, operation.nodeId || operation.nodeName || '', 'updateNode');
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -347,7 +351,7 @@ export class WorkflowDiffEngine {
|
|||||||
private validateMoveNode(workflow: Workflow, operation: MoveNodeOperation): string | null {
|
private validateMoveNode(workflow: Workflow, operation: MoveNodeOperation): string | null {
|
||||||
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
|
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return `Node not found: ${operation.nodeId || operation.nodeName}`;
|
return this.formatNodeNotFoundError(workflow, operation.nodeId || operation.nodeName || '', 'moveNode');
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -355,7 +359,8 @@ export class WorkflowDiffEngine {
|
|||||||
private validateToggleNode(workflow: Workflow, operation: EnableNodeOperation | DisableNodeOperation): string | null {
|
private validateToggleNode(workflow: Workflow, operation: EnableNodeOperation | DisableNodeOperation): string | null {
|
||||||
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
|
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return `Node not found: ${operation.nodeId || operation.nodeName}`;
|
const operationType = operation.type === 'enableNode' ? 'enableNode' : 'disableNode';
|
||||||
|
return this.formatNodeNotFoundError(workflow, operation.nodeId || operation.nodeName || '', operationType);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -384,12 +389,16 @@ export class WorkflowDiffEngine {
|
|||||||
const targetNode = this.findNode(workflow, operation.target, operation.target);
|
const targetNode = this.findNode(workflow, operation.target, operation.target);
|
||||||
|
|
||||||
if (!sourceNode) {
|
if (!sourceNode) {
|
||||||
const availableNodes = workflow.nodes.map(n => n.name).join(', ');
|
const availableNodes = workflow.nodes
|
||||||
return `Source node not found: "${operation.source}". Available nodes: ${availableNodes}`;
|
.map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`)
|
||||||
|
.join(', ');
|
||||||
|
return `Source node not found: "${operation.source}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters (apostrophes, quotes).`;
|
||||||
}
|
}
|
||||||
if (!targetNode) {
|
if (!targetNode) {
|
||||||
const availableNodes = workflow.nodes.map(n => n.name).join(', ');
|
const availableNodes = workflow.nodes
|
||||||
return `Target node not found: "${operation.target}". Available nodes: ${availableNodes}`;
|
.map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`)
|
||||||
|
.join(', ');
|
||||||
|
return `Target node not found: "${operation.target}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters (apostrophes, quotes).`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if connection already exists
|
// Check if connection already exists
|
||||||
@@ -417,10 +426,16 @@ export class WorkflowDiffEngine {
|
|||||||
const targetNode = this.findNode(workflow, operation.target, operation.target);
|
const targetNode = this.findNode(workflow, operation.target, operation.target);
|
||||||
|
|
||||||
if (!sourceNode) {
|
if (!sourceNode) {
|
||||||
return `Source node not found: ${operation.source}`;
|
const availableNodes = workflow.nodes
|
||||||
|
.map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`)
|
||||||
|
.join(', ');
|
||||||
|
return `Source node not found: "${operation.source}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters.`;
|
||||||
}
|
}
|
||||||
if (!targetNode) {
|
if (!targetNode) {
|
||||||
return `Target node not found: ${operation.target}`;
|
const availableNodes = workflow.nodes
|
||||||
|
.map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`)
|
||||||
|
.join(', ');
|
||||||
|
return `Target node not found: "${operation.target}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceOutput = operation.sourceOutput || 'main';
|
const sourceOutput = operation.sourceOutput || 'main';
|
||||||
@@ -791,26 +806,96 @@ export class WorkflowDiffEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper methods
|
// Helper methods
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize node names to handle special characters and escaping differences.
|
||||||
|
* Fixes issue #270: apostrophes and other special characters in node names.
|
||||||
|
*
|
||||||
|
* ⚠️ WARNING: Normalization can cause collisions between names that differ only in:
|
||||||
|
* - Leading/trailing whitespace
|
||||||
|
* - Multiple consecutive spaces vs single spaces
|
||||||
|
* - Escaped vs unescaped quotes/backslashes
|
||||||
|
* - Different types of whitespace (tabs, newlines, spaces)
|
||||||
|
*
|
||||||
|
* Examples of names that normalize to the SAME value:
|
||||||
|
* - "Node 'test'" === "Node 'test'" (multiple spaces)
|
||||||
|
* - "Node 'test'" === "Node\t'test'" (tab vs space)
|
||||||
|
* - "Node 'test'" === "Node \\'test\\'" (escaped quotes)
|
||||||
|
* - "Path\\to\\file" === "Path\\\\to\\\\file" (escaped backslashes)
|
||||||
|
*
|
||||||
|
* Best Practice: For node names with special characters, prefer using node IDs
|
||||||
|
* to avoid ambiguity. Use n8n_get_workflow_structure() to get node IDs.
|
||||||
|
*
|
||||||
|
* @param name - The node name to normalize
|
||||||
|
* @returns Normalized node name for safe comparison
|
||||||
|
*/
|
||||||
|
private normalizeNodeName(name: string): string {
|
||||||
|
return name
|
||||||
|
.trim() // Remove leading/trailing whitespace
|
||||||
|
.replace(/\\\\/g, '\\') // FIRST: Unescape backslashes: \\ -> \ (must be first to handle multiply-escaped chars)
|
||||||
|
.replace(/\\'/g, "'") // THEN: Unescape single quotes: \' -> '
|
||||||
|
.replace(/\\"/g, '"') // THEN: Unescape double quotes: \" -> "
|
||||||
|
.replace(/\s+/g, ' '); // FINALLY: Normalize all whitespace (spaces, tabs, newlines) to single space
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a node by ID or name in the workflow.
|
||||||
|
* Uses string normalization to handle special characters (Issue #270).
|
||||||
|
*
|
||||||
|
* @param workflow - The workflow to search in
|
||||||
|
* @param nodeId - Optional node ID to search for
|
||||||
|
* @param nodeName - Optional node name to search for
|
||||||
|
* @returns The found node or null
|
||||||
|
*/
|
||||||
private findNode(workflow: Workflow, nodeId?: string, nodeName?: string): WorkflowNode | null {
|
private findNode(workflow: Workflow, nodeId?: string, nodeName?: string): WorkflowNode | null {
|
||||||
|
// Try to find by ID first (exact match, no normalization needed for UUIDs)
|
||||||
if (nodeId) {
|
if (nodeId) {
|
||||||
const nodeById = workflow.nodes.find(n => n.id === nodeId);
|
const nodeById = workflow.nodes.find(n => n.id === nodeId);
|
||||||
if (nodeById) return nodeById;
|
if (nodeById) return nodeById;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to find by name with normalization (handles special characters)
|
||||||
if (nodeName) {
|
if (nodeName) {
|
||||||
const nodeByName = workflow.nodes.find(n => n.name === nodeName);
|
const normalizedSearch = this.normalizeNodeName(nodeName);
|
||||||
|
const nodeByName = workflow.nodes.find(n =>
|
||||||
|
this.normalizeNodeName(n.name) === normalizedSearch
|
||||||
|
);
|
||||||
if (nodeByName) return nodeByName;
|
if (nodeByName) return nodeByName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If nodeId is provided but not found, try treating it as a name
|
// Fallback: If nodeId provided but not found, try treating it as a name
|
||||||
|
// This allows operations to work with either IDs or names flexibly
|
||||||
if (nodeId && !nodeName) {
|
if (nodeId && !nodeName) {
|
||||||
const nodeByName = workflow.nodes.find(n => n.name === nodeId);
|
const normalizedSearch = this.normalizeNodeName(nodeId);
|
||||||
|
const nodeByName = workflow.nodes.find(n =>
|
||||||
|
this.normalizeNodeName(n.name) === normalizedSearch
|
||||||
|
);
|
||||||
if (nodeByName) return nodeByName;
|
if (nodeByName) return nodeByName;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a consistent "node not found" error message with helpful context.
|
||||||
|
* Shows available nodes with IDs and tips about using node IDs for special characters.
|
||||||
|
*
|
||||||
|
* @param workflow - The workflow being validated
|
||||||
|
* @param nodeIdentifier - The node ID or name that wasn't found
|
||||||
|
* @param operationType - The operation being performed (e.g., "removeNode", "updateNode")
|
||||||
|
* @returns Formatted error message with available nodes and helpful tips
|
||||||
|
*/
|
||||||
|
private formatNodeNotFoundError(
|
||||||
|
workflow: Workflow,
|
||||||
|
nodeIdentifier: string,
|
||||||
|
operationType: string
|
||||||
|
): string {
|
||||||
|
const availableNodes = workflow.nodes
|
||||||
|
.map(n => `"${n.name}" (id: ${n.id.substring(0, 8)}...)`)
|
||||||
|
.join(', ');
|
||||||
|
return `Node not found for ${operationType}: "${nodeIdentifier}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters (apostrophes, quotes).`;
|
||||||
|
}
|
||||||
|
|
||||||
private setNestedProperty(obj: any, path: string, value: any): void {
|
private setNestedProperty(obj: any, path: string, value: any): void {
|
||||||
const keys = path.split('.');
|
const keys = path.split('.');
|
||||||
let current = obj;
|
let current = obj;
|
||||||
|
|||||||
@@ -3007,4 +3007,352 @@ describe('WorkflowDiffEngine', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Issue #270: Special characters in node names
|
||||||
|
describe('Special Characters in Node Names', () => {
|
||||||
|
it('should handle apostrophes in node names for addConnection', async () => {
|
||||||
|
// Default n8n Manual Trigger node name contains apostrophes
|
||||||
|
const workflowWithApostrophes = {
|
||||||
|
...baseWorkflow,
|
||||||
|
nodes: [
|
||||||
|
...baseWorkflow.nodes,
|
||||||
|
{
|
||||||
|
id: 'manual-trigger-1',
|
||||||
|
name: "When clicking 'Execute workflow'", // Contains apostrophes
|
||||||
|
type: 'n8n-nodes-base.manualTrigger',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [100, 100] as [number, number],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const operation: AddConnectionOperation = {
|
||||||
|
type: 'addConnection',
|
||||||
|
source: "When clicking 'Execute workflow'", // Using node name with apostrophes
|
||||||
|
target: 'HTTP Request'
|
||||||
|
};
|
||||||
|
|
||||||
|
const request: WorkflowDiffRequest = {
|
||||||
|
id: 'test-workflow',
|
||||||
|
operations: [operation]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await diffEngine.applyDiff(workflowWithApostrophes as Workflow, request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.workflow.connections["When clicking 'Execute workflow'"]).toBeDefined();
|
||||||
|
expect(result.workflow.connections["When clicking 'Execute workflow'"].main).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle double quotes in node names', async () => {
|
||||||
|
const workflowWithQuotes = {
|
||||||
|
...baseWorkflow,
|
||||||
|
nodes: [
|
||||||
|
...baseWorkflow.nodes,
|
||||||
|
{
|
||||||
|
id: 'quoted-node-1',
|
||||||
|
name: 'Node with "quotes"', // Contains double quotes
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [100, 100] as [number, number],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const operation: AddConnectionOperation = {
|
||||||
|
type: 'addConnection',
|
||||||
|
source: 'Node with "quotes"',
|
||||||
|
target: 'HTTP Request'
|
||||||
|
};
|
||||||
|
|
||||||
|
const request: WorkflowDiffRequest = {
|
||||||
|
id: 'test-workflow',
|
||||||
|
operations: [operation]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await diffEngine.applyDiff(workflowWithQuotes as Workflow, request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.workflow.connections['Node with "quotes"']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle backslashes in node names', async () => {
|
||||||
|
const workflowWithBackslashes = {
|
||||||
|
...baseWorkflow,
|
||||||
|
nodes: [
|
||||||
|
...baseWorkflow.nodes,
|
||||||
|
{
|
||||||
|
id: 'backslash-node-1',
|
||||||
|
name: 'Path\\with\\backslashes', // Contains backslashes
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [100, 100] as [number, number],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const operation: AddConnectionOperation = {
|
||||||
|
type: 'addConnection',
|
||||||
|
source: 'Path\\with\\backslashes',
|
||||||
|
target: 'HTTP Request'
|
||||||
|
};
|
||||||
|
|
||||||
|
const request: WorkflowDiffRequest = {
|
||||||
|
id: 'test-workflow',
|
||||||
|
operations: [operation]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await diffEngine.applyDiff(workflowWithBackslashes as Workflow, request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.workflow.connections['Path\\with\\backslashes']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed special characters in node names', async () => {
|
||||||
|
const workflowWithMixed = {
|
||||||
|
...baseWorkflow,
|
||||||
|
nodes: [
|
||||||
|
...baseWorkflow.nodes,
|
||||||
|
{
|
||||||
|
id: 'complex-node-1',
|
||||||
|
name: "Complex 'name' with \"quotes\" and \\backslash",
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [100, 100] as [number, number],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const operation: AddConnectionOperation = {
|
||||||
|
type: 'addConnection',
|
||||||
|
source: "Complex 'name' with \"quotes\" and \\backslash",
|
||||||
|
target: 'HTTP Request'
|
||||||
|
};
|
||||||
|
|
||||||
|
const request: WorkflowDiffRequest = {
|
||||||
|
id: 'test-workflow',
|
||||||
|
operations: [operation]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await diffEngine.applyDiff(workflowWithMixed as Workflow, request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.workflow.connections["Complex 'name' with \"quotes\" and \\backslash"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters in removeConnection', async () => {
|
||||||
|
const workflowWithConnections = {
|
||||||
|
...baseWorkflow,
|
||||||
|
nodes: [
|
||||||
|
...baseWorkflow.nodes,
|
||||||
|
{
|
||||||
|
id: 'apostrophe-node-1',
|
||||||
|
name: "Node with 'apostrophes'",
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [100, 100] as [number, number],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
...baseWorkflow.connections,
|
||||||
|
"Node with 'apostrophes'": {
|
||||||
|
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const operation: RemoveConnectionOperation = {
|
||||||
|
type: 'removeConnection',
|
||||||
|
source: "Node with 'apostrophes'",
|
||||||
|
target: 'HTTP Request'
|
||||||
|
};
|
||||||
|
|
||||||
|
const request: WorkflowDiffRequest = {
|
||||||
|
id: 'test-workflow',
|
||||||
|
operations: [operation]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await diffEngine.applyDiff(workflowWithConnections as any, request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.workflow.connections["Node with 'apostrophes'"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters in updateNode', async () => {
|
||||||
|
const workflowWithSpecialNode = {
|
||||||
|
...baseWorkflow,
|
||||||
|
nodes: [
|
||||||
|
...baseWorkflow.nodes,
|
||||||
|
{
|
||||||
|
id: 'special-node-1',
|
||||||
|
name: "Update 'this' node",
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [100, 100] as [number, number],
|
||||||
|
parameters: { value: 'old' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const operation: UpdateNodeOperation = {
|
||||||
|
type: 'updateNode',
|
||||||
|
nodeName: "Update 'this' node",
|
||||||
|
updates: {
|
||||||
|
'parameters.value': 'new'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const request: WorkflowDiffRequest = {
|
||||||
|
id: 'test-workflow',
|
||||||
|
operations: [operation]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await diffEngine.applyDiff(workflowWithSpecialNode as Workflow, request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const updatedNode = result.workflow.nodes.find((n: any) => n.name === "Update 'this' node");
|
||||||
|
expect(updatedNode?.parameters.value).toBe('new');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Code Review Fix: Test whitespace normalization
|
||||||
|
it('should handle tabs in node names', async () => {
|
||||||
|
const workflowWithTabs = {
|
||||||
|
...baseWorkflow,
|
||||||
|
nodes: [
|
||||||
|
...baseWorkflow.nodes,
|
||||||
|
{
|
||||||
|
id: 'tab-node-1',
|
||||||
|
name: "Node\twith\ttabs", // Contains tabs
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [100, 100] as [number, number],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const operation: AddConnectionOperation = {
|
||||||
|
type: 'addConnection',
|
||||||
|
source: "Node\twith\ttabs", // Tabs should normalize to single spaces
|
||||||
|
target: 'HTTP Request'
|
||||||
|
};
|
||||||
|
|
||||||
|
const request: WorkflowDiffRequest = {
|
||||||
|
id: 'test-workflow',
|
||||||
|
operations: [operation]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await diffEngine.applyDiff(workflowWithTabs as Workflow, request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
// After normalization, both "Node\twith\ttabs" and "Node with tabs" should match
|
||||||
|
expect(result.workflow.connections["Node\twith\ttabs"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle newlines in node names', async () => {
|
||||||
|
const workflowWithNewlines = {
|
||||||
|
...baseWorkflow,
|
||||||
|
nodes: [
|
||||||
|
...baseWorkflow.nodes,
|
||||||
|
{
|
||||||
|
id: 'newline-node-1',
|
||||||
|
name: "Node\nwith\nnewlines", // Contains newlines
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [100, 100] as [number, number],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const operation: AddConnectionOperation = {
|
||||||
|
type: 'addConnection',
|
||||||
|
source: "Node\nwith\nnewlines", // Newlines should normalize to single spaces
|
||||||
|
target: 'HTTP Request'
|
||||||
|
};
|
||||||
|
|
||||||
|
const request: WorkflowDiffRequest = {
|
||||||
|
id: 'test-workflow',
|
||||||
|
operations: [operation]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await diffEngine.applyDiff(workflowWithNewlines as Workflow, request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.workflow.connections["Node\nwith\nnewlines"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed whitespace (tabs, newlines, spaces)', async () => {
|
||||||
|
const workflowWithMixed = {
|
||||||
|
...baseWorkflow,
|
||||||
|
nodes: [
|
||||||
|
...baseWorkflow.nodes,
|
||||||
|
{
|
||||||
|
id: 'mixed-whitespace-node-1',
|
||||||
|
name: "Node\t \n with \r\nmixed", // Mixed whitespace
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [100, 100] as [number, number],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const operation: AddConnectionOperation = {
|
||||||
|
type: 'addConnection',
|
||||||
|
source: "Node\t \n with \r\nmixed", // Should normalize all whitespace
|
||||||
|
target: 'HTTP Request'
|
||||||
|
};
|
||||||
|
|
||||||
|
const request: WorkflowDiffRequest = {
|
||||||
|
id: 'test-workflow',
|
||||||
|
operations: [operation]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await diffEngine.applyDiff(workflowWithMixed as Workflow, request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.workflow.connections["Node\t \n with \r\nmixed"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Code Review Fix: Test escaped vs unescaped matching (core issue #270 scenario)
|
||||||
|
it('should match escaped input with unescaped stored names (Issue #270 core scenario)', async () => {
|
||||||
|
// Scenario: AI/JSON-RPC sends escaped name, n8n workflow has unescaped name
|
||||||
|
const workflowWithUnescaped = {
|
||||||
|
...baseWorkflow,
|
||||||
|
nodes: [
|
||||||
|
...baseWorkflow.nodes,
|
||||||
|
{
|
||||||
|
id: 'test-node',
|
||||||
|
name: "When clicking 'Execute workflow'", // Unescaped (how n8n stores it)
|
||||||
|
type: 'n8n-nodes-base.manualTrigger',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [100, 100] as [number, number],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const operation: AddConnectionOperation = {
|
||||||
|
type: 'addConnection',
|
||||||
|
source: "When clicking \\'Execute workflow\\'", // Escaped (how JSON-RPC might send it)
|
||||||
|
target: 'HTTP Request'
|
||||||
|
};
|
||||||
|
|
||||||
|
const request: WorkflowDiffRequest = {
|
||||||
|
id: 'test-workflow',
|
||||||
|
operations: [operation]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await diffEngine.applyDiff(workflowWithUnescaped as Workflow, request);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true); // Should match despite different escaping
|
||||||
|
expect(result.workflow.connections["When clicking 'Execute workflow'"]).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -365,7 +365,28 @@ describe('WorkflowValidator - Edge Cases', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Special Characters and Unicode', () => {
|
describe('Special Characters and Unicode', () => {
|
||||||
it.skip('should handle special characters in node names - FIXME: mock issues', async () => {
|
// Note: These tests are skipped because WorkflowValidator also needs special character
|
||||||
|
// normalization (similar to WorkflowDiffEngine fix in #270). Will be addressed in a future PR.
|
||||||
|
it.skip('should handle apostrophes in node names - TODO: needs WorkflowValidator normalization', async () => {
|
||||||
|
// Test default n8n Manual Trigger node name with apostrophes
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{ id: '1', name: "When clicking 'Execute workflow'", type: 'n8n-nodes-base.manualTrigger', position: [0, 0] as [number, number], parameters: {} },
|
||||||
|
{ id: '2', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest', position: [100, 0] as [number, number], parameters: {} }
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
"When clicking 'Execute workflow'": {
|
||||||
|
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.skip('should handle special characters in node names - TODO: needs WorkflowValidator normalization', async () => {
|
||||||
const workflow = {
|
const workflow = {
|
||||||
nodes: [
|
nodes: [
|
||||||
{ id: '1', name: 'Node@#$%', type: 'n8n-nodes-base.set', position: [0, 0] as [number, number], parameters: {} },
|
{ id: '1', name: 'Node@#$%', type: 'n8n-nodes-base.set', position: [0, 0] as [number, number], parameters: {} },
|
||||||
@@ -381,9 +402,10 @@ describe('WorkflowValidator - Edge Cases', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow as any);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle very long node names', async () => {
|
it('should handle very long node names', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user