mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-04-05 00:53:07 +00:00
Compare commits
2 Commits
v2.22.12
...
fix/sql-js
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11307a9ed2 | ||
|
|
a6ebd1df41 |
221
CHANGELOG.md
221
CHANGELOG.md
@@ -7,227 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [2.22.12] - 2025-01-08
|
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
|
||||||
|
|
||||||
**Issue #392: Helpful Error Messages for "changes" vs "updates" Parameter**
|
|
||||||
|
|
||||||
Fixed cryptic error message when users mistakenly use `changes` instead of `updates` in updateNode operations. AI agents now receive clear, educational error messages that help them self-correct immediately.
|
|
||||||
|
|
||||||
#### Problem
|
|
||||||
|
|
||||||
Users who mistakenly used `changes` instead of `updates` in `n8n_update_partial_workflow` updateNode operations encountered a cryptic error:
|
|
||||||
|
|
||||||
```
|
|
||||||
Diff engine error: Cannot read properties of undefined (reading 'name')
|
|
||||||
```
|
|
||||||
|
|
||||||
This error occurred because:
|
|
||||||
1. The code tried to read `operation.updates.name` at line 406 of `workflow-diff-engine.ts`
|
|
||||||
2. When users sent `changes` instead of `updates`, `operation.updates` was `undefined`
|
|
||||||
3. Reading `.name` from `undefined` → unhelpful error message
|
|
||||||
4. AI agents had no guidance on what went wrong or how to fix it
|
|
||||||
|
|
||||||
**Root Cause**: No early validation to detect this common parameter mistake before attempting to access properties.
|
|
||||||
|
|
||||||
#### Solution
|
|
||||||
|
|
||||||
Added early validation in `validateUpdateNode()` method to detect and provide helpful guidance:
|
|
||||||
|
|
||||||
**1. Parameter Validation** (`src/services/workflow-diff-engine.ts` lines 400-409):
|
|
||||||
```typescript
|
|
||||||
// Check for common parameter mistake: "changes" instead of "updates" (Issue #392)
|
|
||||||
const operationAny = operation as any;
|
|
||||||
if (operationAny.changes && !operation.updates) {
|
|
||||||
return `Invalid parameter 'changes'. The updateNode operation requires 'updates' (not 'changes'). Example: {type: "updateNode", nodeId: "abc", updates: {name: "New Name", "parameters.url": "https://example.com"}}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for missing required parameter
|
|
||||||
if (!operation.updates) {
|
|
||||||
return `Missing required parameter 'updates'. The updateNode operation requires an 'updates' object containing properties to modify. Example: {type: "updateNode", nodeId: "abc", updates: {name: "New Name"}}`;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**2. Documentation Fix** (`docs/VS_CODE_PROJECT_SETUP.md` line 165):
|
|
||||||
- Fixed outdated example that showed incorrect parameter name
|
|
||||||
- Changed from: `{type: 'updateNode', nodeId: 'slack1', changes: {position: [100, 200]}}`
|
|
||||||
- Changed to: `{type: 'updateNode', nodeId: 'slack1', updates: {position: [100, 200]}}`
|
|
||||||
- Prevents AI agents from learning the wrong syntax
|
|
||||||
|
|
||||||
**3. Comprehensive Test Coverage** (`tests/unit/services/workflow-diff-engine.test.ts` lines 388-428):
|
|
||||||
- Test for using `changes` instead of `updates` (validates helpful error message)
|
|
||||||
- Test for missing `updates` parameter entirely
|
|
||||||
- Both tests verify error message content includes examples
|
|
||||||
|
|
||||||
#### Error Messages
|
|
||||||
|
|
||||||
**Before Fix:**
|
|
||||||
```
|
|
||||||
Diff engine error: Cannot read properties of undefined (reading 'name')
|
|
||||||
```
|
|
||||||
|
|
||||||
**After Fix:**
|
|
||||||
```
|
|
||||||
Missing required parameter 'updates'. The updateNode operation requires an 'updates'
|
|
||||||
object containing properties to modify. Example: {type: "updateNode", nodeId: "abc",
|
|
||||||
updates: {name: "New Name"}}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Impact
|
|
||||||
|
|
||||||
**For AI Agents:**
|
|
||||||
- ✅ **Clear Error Messages**: Explicitly states what's wrong ("Invalid parameter 'changes'")
|
|
||||||
- ✅ **Educational**: Explains the correct parameter name ("requires 'updates'")
|
|
||||||
- ✅ **Actionable**: Includes working example showing correct syntax
|
|
||||||
- ✅ **Self-Correction**: AI agents can immediately fix their code based on the error
|
|
||||||
|
|
||||||
**Testing Results:**
|
|
||||||
- Test Coverage: 85% confidence (production ready)
|
|
||||||
- n8n-mcp-tester validation: All 3 test cases passed
|
|
||||||
- Code Review: Approved with minor optional suggestions
|
|
||||||
- Consistency: Follows existing patterns from Issue #249
|
|
||||||
|
|
||||||
**Production Impact:**
|
|
||||||
- **Risk Level**: Very Low (only adds validation, no logic changes)
|
|
||||||
- **Breaking Changes**: None (backward compatible)
|
|
||||||
- **False Positive Rate**: 0% (validation is specific to the exact mistake)
|
|
||||||
|
|
||||||
#### Technical Details
|
|
||||||
|
|
||||||
**Files Modified (3 files):**
|
|
||||||
- `src/services/workflow-diff-engine.ts` - Added early validation (10 lines)
|
|
||||||
- `docs/VS_CODE_PROJECT_SETUP.md` - Fixed incorrect example (1 line)
|
|
||||||
- `tests/unit/services/workflow-diff-engine.test.ts` - Added 2 comprehensive test cases (40 lines)
|
|
||||||
|
|
||||||
**Configuration (1 file):**
|
|
||||||
- `package.json` - Version bump to 2.22.12
|
|
||||||
|
|
||||||
**Validation Flow:**
|
|
||||||
1. Check if operation has `changes` property but no `updates` → Error with helpful message
|
|
||||||
2. Check if operation is missing `updates` entirely → Error with example
|
|
||||||
3. Continue with normal validation if `updates` is present
|
|
||||||
|
|
||||||
**Consistency:**
|
|
||||||
- Pattern matches existing parameter validation in `validateAddConnection()` (lines 444-451)
|
|
||||||
- Error message format consistent with existing errors (lines 461, 466, 469)
|
|
||||||
- Uses same `as any` approach for detecting invalid properties
|
|
||||||
|
|
||||||
#### References
|
|
||||||
|
|
||||||
- **Issue**: #392 - "Diff engine error: Cannot read properties of undefined (reading 'name')"
|
|
||||||
- **Reporter**: User Aldekein (via cmj-hub investigation)
|
|
||||||
- **Test Coverage Assessment**: 85% confidence - SUFFICIENT for production
|
|
||||||
- **Code Review**: APPROVE WITH COMMENTS - Well-implemented and ready to merge
|
|
||||||
- **Related Issues**: None (this is a new validation feature)
|
|
||||||
|
|
||||||
Conceived by Romuald Członkowski - [www.aiadvisors.pl/en](https://www.aiadvisors.pl/en)
|
|
||||||
|
|
||||||
## [2.22.11] - 2025-01-06
|
|
||||||
|
|
||||||
### ✨ New Features
|
|
||||||
|
|
||||||
**Issue #399: Workflow Activation via Diff Operations**
|
|
||||||
|
|
||||||
Added workflow activation and deactivation as diff operations in `n8n_update_partial_workflow`, using n8n's dedicated API endpoints.
|
|
||||||
|
|
||||||
#### Problem
|
|
||||||
|
|
||||||
The n8n API provides dedicated `POST /workflows/{id}/activate` and `POST /workflows/{id}/deactivate` endpoints, but these were not accessible through n8n-mcp. Users could not programmatically control workflow activation status, forcing manual activation through the n8n UI.
|
|
||||||
|
|
||||||
#### Solution
|
|
||||||
|
|
||||||
Implemented activation/deactivation as diff operations, following the established pattern of metadata operations like `updateSettings` and `updateName`. This keeps the tool count manageable (40 tools, not 42) and provides a consistent interface.
|
|
||||||
|
|
||||||
#### Changes
|
|
||||||
|
|
||||||
**API Client** (`src/services/n8n-api-client.ts`):
|
|
||||||
- Added `activateWorkflow(id: string): Promise<Workflow>` method
|
|
||||||
- Added `deactivateWorkflow(id: string): Promise<Workflow>` method
|
|
||||||
- Both use POST requests to dedicated n8n API endpoints
|
|
||||||
|
|
||||||
**Diff Engine Types** (`src/types/workflow-diff.ts`):
|
|
||||||
- Added `ActivateWorkflowOperation` interface
|
|
||||||
- Added `DeactivateWorkflowOperation` interface
|
|
||||||
- Added `shouldActivate` and `shouldDeactivate` flags to `WorkflowDiffResult`
|
|
||||||
- Increased supported operations from 15 to 17
|
|
||||||
|
|
||||||
**Diff Engine** (`src/services/workflow-diff-engine.ts`):
|
|
||||||
- Added validation for activation (requires activatable triggers)
|
|
||||||
- Added operation application logic
|
|
||||||
- Transfers activation intent from workflow object to result
|
|
||||||
- Validates workflow has activatable triggers (webhook, schedule, etc.)
|
|
||||||
- Rejects workflows with only `executeWorkflowTrigger` (cannot activate)
|
|
||||||
|
|
||||||
**Handler** (`src/mcp/handlers-workflow-diff.ts`):
|
|
||||||
- Checks `shouldActivate` and `shouldDeactivate` flags after workflow update
|
|
||||||
- Calls appropriate API methods
|
|
||||||
- Includes activation status in response message and details
|
|
||||||
- Handles activation/deactivation errors gracefully
|
|
||||||
|
|
||||||
**Documentation** (`src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts`):
|
|
||||||
- Updated operation count from 15 to 17
|
|
||||||
- Added "Workflow Activation Operations" section
|
|
||||||
- Added activation tip to essentials
|
|
||||||
|
|
||||||
**Tool Registration** (`src/mcp/handlers-n8n-manager.ts`):
|
|
||||||
- Removed "Cannot activate/deactivate workflows via API" from limitations
|
|
||||||
|
|
||||||
#### Usage
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Activate workflow
|
|
||||||
n8n_update_partial_workflow({
|
|
||||||
id: "workflow_id",
|
|
||||||
operations: [{
|
|
||||||
type: "activateWorkflow"
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
|
|
||||||
// Deactivate workflow
|
|
||||||
n8n_update_partial_workflow({
|
|
||||||
id: "workflow_id",
|
|
||||||
operations: [{
|
|
||||||
type: "deactivateWorkflow"
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
|
|
||||||
// Combine with other operations
|
|
||||||
n8n_update_partial_workflow({
|
|
||||||
id: "workflow_id",
|
|
||||||
operations: [
|
|
||||||
{type: "updateNode", nodeId: "abc", updates: {name: "Updated"}},
|
|
||||||
{type: "activateWorkflow"}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Validation
|
|
||||||
|
|
||||||
- **Activation**: Requires at least one enabled activatable trigger node
|
|
||||||
- **Deactivation**: Always valid
|
|
||||||
- **Error Handling**: Clear messages when activation fails due to missing triggers
|
|
||||||
- **Trigger Detection**: Uses `isActivatableTrigger()` utility (Issue #351 compliance)
|
|
||||||
|
|
||||||
#### Benefits
|
|
||||||
|
|
||||||
- ✅ Consistent with existing architecture (metadata operations pattern)
|
|
||||||
- ✅ Keeps tool count at 40 (not 42)
|
|
||||||
- ✅ Atomic operations - activation happens after workflow update
|
|
||||||
- ✅ Proper validation - prevents activation without triggers
|
|
||||||
- ✅ Clear error messages - guides users on trigger requirements
|
|
||||||
- ✅ Works with other operations - can update and activate in one call
|
|
||||||
|
|
||||||
#### Credits
|
|
||||||
|
|
||||||
- **@ArtemisAI** - Original investigation and API endpoint discovery
|
|
||||||
- **@cmj-hub** - Implementation attempt and PR contribution
|
|
||||||
- Architectural guidance from project maintainer
|
|
||||||
|
|
||||||
Resolves #399
|
|
||||||
|
|
||||||
Conceived by Romuald Członkowski - [www.aiadvisors.pl/en](https://www.aiadvisors.pl/en)
|
|
||||||
|
|
||||||
## [2.22.10] - 2025-11-04
|
## [2.22.10] - 2025-11-04
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
### 🐛 Bug Fixes
|
||||||
|
|||||||
@@ -70,4 +70,4 @@ We may update this privacy policy from time to time. Updates will be reflected i
|
|||||||
For questions about telemetry or privacy, please open an issue on GitHub:
|
For questions about telemetry or privacy, please open an issue on GitHub:
|
||||||
https://github.com/czlonkowski/n8n-mcp/issues
|
https://github.com/czlonkowski/n8n-mcp/issues
|
||||||
|
|
||||||
Last updated: 2025-11-06
|
Last updated: 2025-09-25
|
||||||
BIN
data/nodes.db
BIN
data/nodes.db
Binary file not shown.
@@ -162,7 +162,7 @@ n8n_validate_workflow({id: createdWorkflowId})
|
|||||||
n8n_update_partial_workflow({
|
n8n_update_partial_workflow({
|
||||||
workflowId: id,
|
workflowId: id,
|
||||||
operations: [
|
operations: [
|
||||||
{type: 'updateNode', nodeId: 'slack1', updates: {position: [100, 200]}}
|
{type: 'updateNode', nodeId: 'slack1', changes: {position: [100, 200]}}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.22.12",
|
"version": "2.22.10",
|
||||||
"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",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp-runtime",
|
"name": "n8n-mcp-runtime",
|
||||||
"version": "2.22.11",
|
"version": "2.22.10",
|
||||||
"description": "n8n MCP Server Runtime Dependencies Only",
|
"description": "n8n MCP Server Runtime Dependencies Only",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1561,6 +1561,7 @@ export async function handleListAvailableTools(context?: InstanceContext): Promi
|
|||||||
maxRetries: config.maxRetries
|
maxRetries: config.maxRetries
|
||||||
} : null,
|
} : null,
|
||||||
limitations: [
|
limitations: [
|
||||||
|
'Cannot activate/deactivate workflows via API',
|
||||||
'Cannot execute workflows directly (must use webhooks)',
|
'Cannot execute workflows directly (must use webhooks)',
|
||||||
'Cannot stop running executions',
|
'Cannot stop running executions',
|
||||||
'Tags and credentials have limited API support'
|
'Tags and credentials have limited API support'
|
||||||
|
|||||||
@@ -245,52 +245,15 @@ export async function handleUpdatePartialWorkflow(
|
|||||||
// Update workflow via API
|
// Update workflow via API
|
||||||
try {
|
try {
|
||||||
const updatedWorkflow = await client.updateWorkflow(input.id, diffResult.workflow!);
|
const updatedWorkflow = await client.updateWorkflow(input.id, diffResult.workflow!);
|
||||||
|
|
||||||
// Handle activation/deactivation if requested
|
|
||||||
let finalWorkflow = updatedWorkflow;
|
|
||||||
let activationMessage = '';
|
|
||||||
|
|
||||||
if (diffResult.shouldActivate) {
|
|
||||||
try {
|
|
||||||
finalWorkflow = await client.activateWorkflow(input.id);
|
|
||||||
activationMessage = ' Workflow activated.';
|
|
||||||
} catch (activationError) {
|
|
||||||
logger.error('Failed to activate workflow after update', activationError);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Workflow updated successfully but activation failed',
|
|
||||||
details: {
|
|
||||||
workflowUpdated: true,
|
|
||||||
activationError: activationError instanceof Error ? activationError.message : 'Unknown error'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else if (diffResult.shouldDeactivate) {
|
|
||||||
try {
|
|
||||||
finalWorkflow = await client.deactivateWorkflow(input.id);
|
|
||||||
activationMessage = ' Workflow deactivated.';
|
|
||||||
} catch (deactivationError) {
|
|
||||||
logger.error('Failed to deactivate workflow after update', deactivationError);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Workflow updated successfully but deactivation failed',
|
|
||||||
details: {
|
|
||||||
workflowUpdated: true,
|
|
||||||
deactivationError: deactivationError instanceof Error ? deactivationError.message : 'Unknown error'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: finalWorkflow,
|
data: updatedWorkflow,
|
||||||
message: `Workflow "${finalWorkflow.name}" updated successfully. Applied ${diffResult.operationsApplied} operations.${activationMessage}`,
|
message: `Workflow "${updatedWorkflow.name}" updated successfully. Applied ${diffResult.operationsApplied} operations.`,
|
||||||
details: {
|
details: {
|
||||||
operationsApplied: diffResult.operationsApplied,
|
operationsApplied: diffResult.operationsApplied,
|
||||||
workflowId: finalWorkflow.id,
|
workflowId: updatedWorkflow.id,
|
||||||
workflowName: finalWorkflow.name,
|
workflowName: updatedWorkflow.name,
|
||||||
active: finalWorkflow.active,
|
|
||||||
applied: diffResult.applied,
|
applied: diffResult.applied,
|
||||||
failed: diffResult.failed,
|
failed: diffResult.failed,
|
||||||
errors: diffResult.errors,
|
errors: diffResult.errors,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export const n8nUpdatePartialWorkflowDoc: ToolDocumentation = {
|
|||||||
name: 'n8n_update_partial_workflow',
|
name: 'n8n_update_partial_workflow',
|
||||||
category: 'workflow_management',
|
category: 'workflow_management',
|
||||||
essentials: {
|
essentials: {
|
||||||
description: 'Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, moveNode, enable/disableNode, addConnection, removeConnection, rewireConnection, cleanStaleConnections, replaceConnections, updateSettings, updateName, add/removeTag, activateWorkflow, deactivateWorkflow. Supports smart parameters (branch, case) for multi-output nodes. Full support for AI connections (ai_languageModel, ai_tool, ai_memory, ai_embedding, ai_vectorStore, ai_document, ai_textSplitter, ai_outputParser).',
|
description: 'Update workflow incrementally with diff operations. Types: addNode, removeNode, updateNode, moveNode, enable/disableNode, addConnection, removeConnection, rewireConnection, cleanStaleConnections, replaceConnections, updateSettings, updateName, add/removeTag. Supports smart parameters (branch, case) for multi-output nodes. Full support for AI connections (ai_languageModel, ai_tool, ai_memory, ai_embedding, ai_vectorStore, ai_document, ai_textSplitter, ai_outputParser).',
|
||||||
keyParameters: ['id', 'operations', 'continueOnError'],
|
keyParameters: ['id', 'operations', 'continueOnError'],
|
||||||
example: 'n8n_update_partial_workflow({id: "wf_123", operations: [{type: "rewireConnection", source: "IF", from: "Old", to: "New", branch: "true"}]})',
|
example: 'n8n_update_partial_workflow({id: "wf_123", operations: [{type: "rewireConnection", source: "IF", from: "Old", to: "New", branch: "true"}]})',
|
||||||
performance: 'Fast (50-200ms)',
|
performance: 'Fast (50-200ms)',
|
||||||
@@ -19,12 +19,11 @@ export const n8nUpdatePartialWorkflowDoc: ToolDocumentation = {
|
|||||||
'For AI connections, specify sourceOutput type (ai_languageModel, ai_tool, etc.)',
|
'For AI connections, specify sourceOutput type (ai_languageModel, ai_tool, etc.)',
|
||||||
'Batch AI component connections for atomic updates',
|
'Batch AI component connections for atomic updates',
|
||||||
'Auto-sanitization: ALL nodes auto-fixed during updates (operator structures, missing metadata)',
|
'Auto-sanitization: ALL nodes auto-fixed during updates (operator structures, missing metadata)',
|
||||||
'Node renames automatically update all connection references - no manual connection operations needed',
|
'Node renames automatically update all connection references - no manual connection operations needed'
|
||||||
'Activate/deactivate workflows: Use activateWorkflow/deactivateWorkflow operations (requires activatable triggers like webhook/schedule)'
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
full: {
|
full: {
|
||||||
description: `Updates workflows using surgical diff operations instead of full replacement. Supports 17 operation types for precise modifications. Operations are validated and applied atomically by default - all succeed or none are applied.
|
description: `Updates workflows using surgical diff operations instead of full replacement. Supports 15 operation types for precise modifications. Operations are validated and applied atomically by default - all succeed or none are applied.
|
||||||
|
|
||||||
## Available Operations:
|
## Available Operations:
|
||||||
|
|
||||||
@@ -49,10 +48,6 @@ export const n8nUpdatePartialWorkflowDoc: ToolDocumentation = {
|
|||||||
- **addTag**: Add a workflow tag
|
- **addTag**: Add a workflow tag
|
||||||
- **removeTag**: Remove a workflow tag
|
- **removeTag**: Remove a workflow tag
|
||||||
|
|
||||||
### Workflow Activation Operations (2 types):
|
|
||||||
- **activateWorkflow**: Activate the workflow to enable automatic execution via triggers
|
|
||||||
- **deactivateWorkflow**: Deactivate the workflow to prevent automatic execution
|
|
||||||
|
|
||||||
## Smart Parameters for Multi-Output Nodes
|
## Smart Parameters for Multi-Output Nodes
|
||||||
|
|
||||||
For **IF nodes**, use semantic 'branch' parameter instead of technical sourceIndex:
|
For **IF nodes**, use semantic 'branch' parameter instead of technical sourceIndex:
|
||||||
|
|||||||
@@ -170,24 +170,6 @@ export class N8nApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async activateWorkflow(id: string): Promise<Workflow> {
|
|
||||||
try {
|
|
||||||
const response = await this.client.post(`/workflows/${id}/activate`);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw handleN8nApiError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async deactivateWorkflow(id: string): Promise<Workflow> {
|
|
||||||
try {
|
|
||||||
const response = await this.client.post(`/workflows/${id}/deactivate`);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
throw handleN8nApiError(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lists workflows from n8n instance.
|
* Lists workflows from n8n instance.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ import {
|
|||||||
UpdateNameOperation,
|
UpdateNameOperation,
|
||||||
AddTagOperation,
|
AddTagOperation,
|
||||||
RemoveTagOperation,
|
RemoveTagOperation,
|
||||||
ActivateWorkflowOperation,
|
|
||||||
DeactivateWorkflowOperation,
|
|
||||||
CleanStaleConnectionsOperation,
|
CleanStaleConnectionsOperation,
|
||||||
ReplaceConnectionsOperation
|
ReplaceConnectionsOperation
|
||||||
} from '../types/workflow-diff';
|
} from '../types/workflow-diff';
|
||||||
@@ -34,7 +32,6 @@ import { Workflow, WorkflowNode, WorkflowConnection } from '../types/n8n-api';
|
|||||||
import { Logger } from '../utils/logger';
|
import { Logger } from '../utils/logger';
|
||||||
import { validateWorkflowNode, validateWorkflowConnections } from './n8n-validation';
|
import { validateWorkflowNode, validateWorkflowConnections } from './n8n-validation';
|
||||||
import { sanitizeNode, sanitizeWorkflowNodes } from './node-sanitizer';
|
import { sanitizeNode, sanitizeWorkflowNodes } from './node-sanitizer';
|
||||||
import { isActivatableTrigger } from '../utils/node-type-utils';
|
|
||||||
|
|
||||||
const logger = new Logger({ prefix: '[WorkflowDiffEngine]' });
|
const logger = new Logger({ prefix: '[WorkflowDiffEngine]' });
|
||||||
|
|
||||||
@@ -217,23 +214,12 @@ export class WorkflowDiffEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const operationsApplied = request.operations.length;
|
const operationsApplied = request.operations.length;
|
||||||
|
|
||||||
// Extract activation flags from workflow object
|
|
||||||
const shouldActivate = (workflowCopy as any)._shouldActivate === true;
|
|
||||||
const shouldDeactivate = (workflowCopy as any)._shouldDeactivate === true;
|
|
||||||
|
|
||||||
// Clean up temporary flags
|
|
||||||
delete (workflowCopy as any)._shouldActivate;
|
|
||||||
delete (workflowCopy as any)._shouldDeactivate;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
workflow: workflowCopy,
|
workflow: workflowCopy,
|
||||||
operationsApplied,
|
operationsApplied,
|
||||||
message: `Successfully applied ${operationsApplied} operations (${nodeOperations.length} node ops, ${otherOperations.length} other ops)`,
|
message: `Successfully applied ${operationsApplied} operations (${nodeOperations.length} node ops, ${otherOperations.length} other ops)`,
|
||||||
warnings: this.warnings.length > 0 ? this.warnings : undefined,
|
warnings: this.warnings.length > 0 ? this.warnings : undefined
|
||||||
shouldActivate: shouldActivate || undefined,
|
|
||||||
shouldDeactivate: shouldDeactivate || undefined
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -276,10 +262,6 @@ export class WorkflowDiffEngine {
|
|||||||
case 'addTag':
|
case 'addTag':
|
||||||
case 'removeTag':
|
case 'removeTag':
|
||||||
return null; // These are always valid
|
return null; // These are always valid
|
||||||
case 'activateWorkflow':
|
|
||||||
return this.validateActivateWorkflow(workflow, operation);
|
|
||||||
case 'deactivateWorkflow':
|
|
||||||
return this.validateDeactivateWorkflow(workflow, operation);
|
|
||||||
case 'cleanStaleConnections':
|
case 'cleanStaleConnections':
|
||||||
return this.validateCleanStaleConnections(workflow, operation);
|
return this.validateCleanStaleConnections(workflow, operation);
|
||||||
case 'replaceConnections':
|
case 'replaceConnections':
|
||||||
@@ -333,12 +315,6 @@ export class WorkflowDiffEngine {
|
|||||||
case 'removeTag':
|
case 'removeTag':
|
||||||
this.applyRemoveTag(workflow, operation);
|
this.applyRemoveTag(workflow, operation);
|
||||||
break;
|
break;
|
||||||
case 'activateWorkflow':
|
|
||||||
this.applyActivateWorkflow(workflow, operation);
|
|
||||||
break;
|
|
||||||
case 'deactivateWorkflow':
|
|
||||||
this.applyDeactivateWorkflow(workflow, operation);
|
|
||||||
break;
|
|
||||||
case 'cleanStaleConnections':
|
case 'cleanStaleConnections':
|
||||||
this.applyCleanStaleConnections(workflow, operation);
|
this.applyCleanStaleConnections(workflow, operation);
|
||||||
break;
|
break;
|
||||||
@@ -397,17 +373,6 @@ export class WorkflowDiffEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private validateUpdateNode(workflow: Workflow, operation: UpdateNodeOperation): string | null {
|
private validateUpdateNode(workflow: Workflow, operation: UpdateNodeOperation): string | null {
|
||||||
// Check for common parameter mistake: "changes" instead of "updates" (Issue #392)
|
|
||||||
const operationAny = operation as any;
|
|
||||||
if (operationAny.changes && !operation.updates) {
|
|
||||||
return `Invalid parameter 'changes'. The updateNode operation requires 'updates' (not 'changes'). Example: {type: "updateNode", nodeId: "abc", updates: {name: "New Name", "parameters.url": "https://example.com"}}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for missing required parameter
|
|
||||||
if (!operation.updates) {
|
|
||||||
return `Missing required parameter 'updates'. The updateNode operation requires an 'updates' object containing properties to modify. Example: {type: "updateNode", nodeId: "abc", updates: {name: "New Name"}}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
|
const node = this.findNode(workflow, operation.nodeId, operation.nodeName);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
return this.formatNodeNotFoundError(workflow, operation.nodeId || operation.nodeName || '', 'updateNode');
|
return this.formatNodeNotFoundError(workflow, operation.nodeId || operation.nodeName || '', 'updateNode');
|
||||||
@@ -882,46 +847,13 @@ export class WorkflowDiffEngine {
|
|||||||
|
|
||||||
private applyRemoveTag(workflow: Workflow, operation: RemoveTagOperation): void {
|
private applyRemoveTag(workflow: Workflow, operation: RemoveTagOperation): void {
|
||||||
if (!workflow.tags) return;
|
if (!workflow.tags) return;
|
||||||
|
|
||||||
const index = workflow.tags.indexOf(operation.tag);
|
const index = workflow.tags.indexOf(operation.tag);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
workflow.tags.splice(index, 1);
|
workflow.tags.splice(index, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workflow activation operation validators
|
|
||||||
private validateActivateWorkflow(workflow: Workflow, operation: ActivateWorkflowOperation): string | null {
|
|
||||||
// Check if workflow has at least one activatable trigger
|
|
||||||
// Issue #351: executeWorkflowTrigger cannot activate workflows
|
|
||||||
const activatableTriggers = workflow.nodes.filter(
|
|
||||||
node => !node.disabled && isActivatableTrigger(node.type)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (activatableTriggers.length === 0) {
|
|
||||||
return 'Cannot activate workflow: No activatable trigger nodes found. Workflows must have at least one enabled trigger node (webhook, schedule, email, etc.). Note: executeWorkflowTrigger cannot activate workflows as they can only be invoked by other workflows.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private validateDeactivateWorkflow(workflow: Workflow, operation: DeactivateWorkflowOperation): string | null {
|
|
||||||
// Deactivation is always valid - any workflow can be deactivated
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Workflow activation operation appliers
|
|
||||||
private applyActivateWorkflow(workflow: Workflow, operation: ActivateWorkflowOperation): void {
|
|
||||||
// Set flag in workflow object to indicate activation intent
|
|
||||||
// The handler will call the API method after workflow update
|
|
||||||
(workflow as any)._shouldActivate = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyDeactivateWorkflow(workflow: Workflow, operation: DeactivateWorkflowOperation): void {
|
|
||||||
// Set flag in workflow object to indicate deactivation intent
|
|
||||||
// The handler will call the API method after workflow update
|
|
||||||
(workflow as any)._shouldDeactivate = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection cleanup operation validators
|
// Connection cleanup operation validators
|
||||||
private validateCleanStaleConnections(workflow: Workflow, operation: CleanStaleConnectionsOperation): string | null {
|
private validateCleanStaleConnections(workflow: Workflow, operation: CleanStaleConnectionsOperation): string | null {
|
||||||
// This operation is always valid - it just cleans up what it finds
|
// This operation is always valid - it just cleans up what it finds
|
||||||
|
|||||||
@@ -114,16 +114,6 @@ export interface RemoveTagOperation extends DiffOperation {
|
|||||||
tag: string;
|
tag: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivateWorkflowOperation extends DiffOperation {
|
|
||||||
type: 'activateWorkflow';
|
|
||||||
// No additional properties needed - just activates the workflow
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeactivateWorkflowOperation extends DiffOperation {
|
|
||||||
type: 'deactivateWorkflow';
|
|
||||||
// No additional properties needed - just deactivates the workflow
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connection Cleanup Operations
|
// Connection Cleanup Operations
|
||||||
export interface CleanStaleConnectionsOperation extends DiffOperation {
|
export interface CleanStaleConnectionsOperation extends DiffOperation {
|
||||||
type: 'cleanStaleConnections';
|
type: 'cleanStaleConnections';
|
||||||
@@ -158,8 +148,6 @@ export type WorkflowDiffOperation =
|
|||||||
| UpdateNameOperation
|
| UpdateNameOperation
|
||||||
| AddTagOperation
|
| AddTagOperation
|
||||||
| RemoveTagOperation
|
| RemoveTagOperation
|
||||||
| ActivateWorkflowOperation
|
|
||||||
| DeactivateWorkflowOperation
|
|
||||||
| CleanStaleConnectionsOperation
|
| CleanStaleConnectionsOperation
|
||||||
| ReplaceConnectionsOperation;
|
| ReplaceConnectionsOperation;
|
||||||
|
|
||||||
@@ -188,8 +176,6 @@ export interface WorkflowDiffResult {
|
|||||||
applied?: number[]; // Indices of successfully applied operations (when continueOnError is true)
|
applied?: number[]; // Indices of successfully applied operations (when continueOnError is true)
|
||||||
failed?: number[]; // Indices of failed operations (when continueOnError is true)
|
failed?: number[]; // Indices of failed operations (when continueOnError is true)
|
||||||
staleConnectionsRemoved?: Array<{ from: string; to: string }>; // For cleanStaleConnections operation
|
staleConnectionsRemoved?: Array<{ from: string; to: string }>; // For cleanStaleConnections operation
|
||||||
shouldActivate?: boolean; // Flag to activate workflow after update (for activateWorkflow operation)
|
|
||||||
shouldDeactivate?: boolean; // Flag to deactivate workflow after update (for deactivateWorkflow operation)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper type for node reference (supports both ID and name)
|
// Helper type for node reference (supports both ID and name)
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ describe('Integration: handleListAvailableTools', () => {
|
|||||||
|
|
||||||
// Common known limitations
|
// Common known limitations
|
||||||
const limitationsText = data.limitations.join(' ');
|
const limitationsText = data.limitations.join(' ');
|
||||||
|
expect(limitationsText).toContain('Cannot activate');
|
||||||
expect(limitationsText).toContain('Cannot execute workflows directly');
|
expect(limitationsText).toContain('Cannot execute workflows directly');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -156,11 +156,9 @@ describe('handlers-workflow-diff', () => {
|
|||||||
operationsApplied: 1,
|
operationsApplied: 1,
|
||||||
workflowId: 'test-workflow-id',
|
workflowId: 'test-workflow-id',
|
||||||
workflowName: 'Test Workflow',
|
workflowName: 'Test Workflow',
|
||||||
active: true,
|
|
||||||
applied: [0],
|
applied: [0],
|
||||||
failed: [],
|
failed: [],
|
||||||
errors: [],
|
errors: [],
|
||||||
warnings: undefined,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -635,211 +633,5 @@ describe('handlers-workflow-diff', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Workflow Activation/Deactivation', () => {
|
|
||||||
it('should activate workflow after successful update', async () => {
|
|
||||||
const testWorkflow = createTestWorkflow({ active: false });
|
|
||||||
const updatedWorkflow = { ...testWorkflow, active: false };
|
|
||||||
const activatedWorkflow = { ...testWorkflow, active: true };
|
|
||||||
|
|
||||||
mockApiClient.getWorkflow.mockResolvedValue(testWorkflow);
|
|
||||||
mockDiffEngine.applyDiff.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
workflow: updatedWorkflow,
|
|
||||||
operationsApplied: 1,
|
|
||||||
message: 'Success',
|
|
||||||
errors: [],
|
|
||||||
shouldActivate: true,
|
|
||||||
});
|
|
||||||
mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow);
|
|
||||||
mockApiClient.activateWorkflow = vi.fn().mockResolvedValue(activatedWorkflow);
|
|
||||||
|
|
||||||
const result = await handleUpdatePartialWorkflow({
|
|
||||||
id: 'test-workflow-id',
|
|
||||||
operations: [{ type: 'activateWorkflow' }],
|
|
||||||
}, mockRepository);
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.data).toEqual(activatedWorkflow);
|
|
||||||
expect(result.message).toContain('Workflow activated');
|
|
||||||
expect(result.details?.active).toBe(true);
|
|
||||||
expect(mockApiClient.activateWorkflow).toHaveBeenCalledWith('test-workflow-id');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should deactivate workflow after successful update', async () => {
|
|
||||||
const testWorkflow = createTestWorkflow({ active: true });
|
|
||||||
const updatedWorkflow = { ...testWorkflow, active: true };
|
|
||||||
const deactivatedWorkflow = { ...testWorkflow, active: false };
|
|
||||||
|
|
||||||
mockApiClient.getWorkflow.mockResolvedValue(testWorkflow);
|
|
||||||
mockDiffEngine.applyDiff.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
workflow: updatedWorkflow,
|
|
||||||
operationsApplied: 1,
|
|
||||||
message: 'Success',
|
|
||||||
errors: [],
|
|
||||||
shouldDeactivate: true,
|
|
||||||
});
|
|
||||||
mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow);
|
|
||||||
mockApiClient.deactivateWorkflow = vi.fn().mockResolvedValue(deactivatedWorkflow);
|
|
||||||
|
|
||||||
const result = await handleUpdatePartialWorkflow({
|
|
||||||
id: 'test-workflow-id',
|
|
||||||
operations: [{ type: 'deactivateWorkflow' }],
|
|
||||||
}, mockRepository);
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.data).toEqual(deactivatedWorkflow);
|
|
||||||
expect(result.message).toContain('Workflow deactivated');
|
|
||||||
expect(result.details?.active).toBe(false);
|
|
||||||
expect(mockApiClient.deactivateWorkflow).toHaveBeenCalledWith('test-workflow-id');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle activation failure after successful update', async () => {
|
|
||||||
const testWorkflow = createTestWorkflow({ active: false });
|
|
||||||
const updatedWorkflow = { ...testWorkflow, active: false };
|
|
||||||
|
|
||||||
mockApiClient.getWorkflow.mockResolvedValue(testWorkflow);
|
|
||||||
mockDiffEngine.applyDiff.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
workflow: updatedWorkflow,
|
|
||||||
operationsApplied: 1,
|
|
||||||
message: 'Success',
|
|
||||||
errors: [],
|
|
||||||
shouldActivate: true,
|
|
||||||
});
|
|
||||||
mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow);
|
|
||||||
mockApiClient.activateWorkflow = vi.fn().mockRejectedValue(new Error('Activation failed: No trigger nodes'));
|
|
||||||
|
|
||||||
const result = await handleUpdatePartialWorkflow({
|
|
||||||
id: 'test-workflow-id',
|
|
||||||
operations: [{ type: 'activateWorkflow' }],
|
|
||||||
}, mockRepository);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.error).toBe('Workflow updated successfully but activation failed');
|
|
||||||
expect(result.details).toEqual({
|
|
||||||
workflowUpdated: true,
|
|
||||||
activationError: 'Activation failed: No trigger nodes',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle deactivation failure after successful update', async () => {
|
|
||||||
const testWorkflow = createTestWorkflow({ active: true });
|
|
||||||
const updatedWorkflow = { ...testWorkflow, active: true };
|
|
||||||
|
|
||||||
mockApiClient.getWorkflow.mockResolvedValue(testWorkflow);
|
|
||||||
mockDiffEngine.applyDiff.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
workflow: updatedWorkflow,
|
|
||||||
operationsApplied: 1,
|
|
||||||
message: 'Success',
|
|
||||||
errors: [],
|
|
||||||
shouldDeactivate: true,
|
|
||||||
});
|
|
||||||
mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow);
|
|
||||||
mockApiClient.deactivateWorkflow = vi.fn().mockRejectedValue(new Error('Deactivation failed'));
|
|
||||||
|
|
||||||
const result = await handleUpdatePartialWorkflow({
|
|
||||||
id: 'test-workflow-id',
|
|
||||||
operations: [{ type: 'deactivateWorkflow' }],
|
|
||||||
}, mockRepository);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.error).toBe('Workflow updated successfully but deactivation failed');
|
|
||||||
expect(result.details).toEqual({
|
|
||||||
workflowUpdated: true,
|
|
||||||
deactivationError: 'Deactivation failed',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update workflow without activation when shouldActivate is false', async () => {
|
|
||||||
const testWorkflow = createTestWorkflow({ active: false });
|
|
||||||
const updatedWorkflow = { ...testWorkflow, active: false };
|
|
||||||
|
|
||||||
mockApiClient.getWorkflow.mockResolvedValue(testWorkflow);
|
|
||||||
mockDiffEngine.applyDiff.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
workflow: updatedWorkflow,
|
|
||||||
operationsApplied: 1,
|
|
||||||
message: 'Success',
|
|
||||||
errors: [],
|
|
||||||
shouldActivate: false,
|
|
||||||
shouldDeactivate: false,
|
|
||||||
});
|
|
||||||
mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow);
|
|
||||||
mockApiClient.activateWorkflow = vi.fn();
|
|
||||||
mockApiClient.deactivateWorkflow = vi.fn();
|
|
||||||
|
|
||||||
const result = await handleUpdatePartialWorkflow({
|
|
||||||
id: 'test-workflow-id',
|
|
||||||
operations: [{ type: 'updateName', name: 'Updated' }],
|
|
||||||
}, mockRepository);
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.message).not.toContain('activated');
|
|
||||||
expect(result.message).not.toContain('deactivated');
|
|
||||||
expect(mockApiClient.activateWorkflow).not.toHaveBeenCalled();
|
|
||||||
expect(mockApiClient.deactivateWorkflow).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle non-Error activation failures', async () => {
|
|
||||||
const testWorkflow = createTestWorkflow({ active: false });
|
|
||||||
const updatedWorkflow = { ...testWorkflow, active: false };
|
|
||||||
|
|
||||||
mockApiClient.getWorkflow.mockResolvedValue(testWorkflow);
|
|
||||||
mockDiffEngine.applyDiff.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
workflow: updatedWorkflow,
|
|
||||||
operationsApplied: 1,
|
|
||||||
message: 'Success',
|
|
||||||
errors: [],
|
|
||||||
shouldActivate: true,
|
|
||||||
});
|
|
||||||
mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow);
|
|
||||||
mockApiClient.activateWorkflow = vi.fn().mockRejectedValue('String error');
|
|
||||||
|
|
||||||
const result = await handleUpdatePartialWorkflow({
|
|
||||||
id: 'test-workflow-id',
|
|
||||||
operations: [{ type: 'activateWorkflow' }],
|
|
||||||
}, mockRepository);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.error).toBe('Workflow updated successfully but activation failed');
|
|
||||||
expect(result.details).toEqual({
|
|
||||||
workflowUpdated: true,
|
|
||||||
activationError: 'Unknown error',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle non-Error deactivation failures', async () => {
|
|
||||||
const testWorkflow = createTestWorkflow({ active: true });
|
|
||||||
const updatedWorkflow = { ...testWorkflow, active: true };
|
|
||||||
|
|
||||||
mockApiClient.getWorkflow.mockResolvedValue(testWorkflow);
|
|
||||||
mockDiffEngine.applyDiff.mockResolvedValue({
|
|
||||||
success: true,
|
|
||||||
workflow: updatedWorkflow,
|
|
||||||
operationsApplied: 1,
|
|
||||||
message: 'Success',
|
|
||||||
errors: [],
|
|
||||||
shouldDeactivate: true,
|
|
||||||
});
|
|
||||||
mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow);
|
|
||||||
mockApiClient.deactivateWorkflow = vi.fn().mockRejectedValue({ code: 'UNKNOWN' });
|
|
||||||
|
|
||||||
const result = await handleUpdatePartialWorkflow({
|
|
||||||
id: 'test-workflow-id',
|
|
||||||
operations: [{ type: 'deactivateWorkflow' }],
|
|
||||||
}, mockRepository);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.error).toBe('Workflow updated successfully but deactivation failed');
|
|
||||||
expect(result.details).toEqual({
|
|
||||||
workflowUpdated: true,
|
|
||||||
deactivationError: 'Unknown error',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -362,19 +362,19 @@ describe('N8nApiClient', () => {
|
|||||||
|
|
||||||
it('should delete workflow successfully', async () => {
|
it('should delete workflow successfully', async () => {
|
||||||
mockAxiosInstance.delete.mockResolvedValue({ data: {} });
|
mockAxiosInstance.delete.mockResolvedValue({ data: {} });
|
||||||
|
|
||||||
await client.deleteWorkflow('123');
|
await client.deleteWorkflow('123');
|
||||||
|
|
||||||
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/workflows/123');
|
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/workflows/123');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle deletion error', async () => {
|
it('should handle deletion error', async () => {
|
||||||
const error = {
|
const error = {
|
||||||
message: 'Request failed',
|
message: 'Request failed',
|
||||||
response: { status: 404, data: { message: 'Not found' } }
|
response: { status: 404, data: { message: 'Not found' } }
|
||||||
};
|
};
|
||||||
await mockAxiosInstance.simulateError('delete', error);
|
await mockAxiosInstance.simulateError('delete', error);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.deleteWorkflow('123');
|
await client.deleteWorkflow('123');
|
||||||
expect.fail('Should have thrown an error');
|
expect.fail('Should have thrown an error');
|
||||||
@@ -386,178 +386,6 @@ describe('N8nApiClient', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('activateWorkflow', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
client = new N8nApiClient(defaultConfig);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should activate workflow successfully', async () => {
|
|
||||||
const workflow = { id: '123', name: 'Test', active: false, nodes: [], connections: {} };
|
|
||||||
const activatedWorkflow = { ...workflow, active: true };
|
|
||||||
mockAxiosInstance.post.mockResolvedValue({ data: activatedWorkflow });
|
|
||||||
|
|
||||||
const result = await client.activateWorkflow('123');
|
|
||||||
|
|
||||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/workflows/123/activate');
|
|
||||||
expect(result).toEqual(activatedWorkflow);
|
|
||||||
expect(result.active).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle activation error - no trigger nodes', async () => {
|
|
||||||
const error = {
|
|
||||||
message: 'Request failed',
|
|
||||||
response: { status: 400, data: { message: 'Workflow must have at least one trigger node' } }
|
|
||||||
};
|
|
||||||
await mockAxiosInstance.simulateError('post', error);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.activateWorkflow('123');
|
|
||||||
expect.fail('Should have thrown an error');
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).toBeInstanceOf(N8nValidationError);
|
|
||||||
expect((err as N8nValidationError).message).toContain('trigger node');
|
|
||||||
expect((err as N8nValidationError).statusCode).toBe(400);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle activation error - workflow not found', async () => {
|
|
||||||
const error = {
|
|
||||||
message: 'Request failed',
|
|
||||||
response: { status: 404, data: { message: 'Workflow not found' } }
|
|
||||||
};
|
|
||||||
await mockAxiosInstance.simulateError('post', error);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.activateWorkflow('non-existent');
|
|
||||||
expect.fail('Should have thrown an error');
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).toBeInstanceOf(N8nNotFoundError);
|
|
||||||
expect((err as N8nNotFoundError).message).toContain('not found');
|
|
||||||
expect((err as N8nNotFoundError).statusCode).toBe(404);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle activation error - workflow already active', async () => {
|
|
||||||
const error = {
|
|
||||||
message: 'Request failed',
|
|
||||||
response: { status: 400, data: { message: 'Workflow is already active' } }
|
|
||||||
};
|
|
||||||
await mockAxiosInstance.simulateError('post', error);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.activateWorkflow('123');
|
|
||||||
expect.fail('Should have thrown an error');
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).toBeInstanceOf(N8nValidationError);
|
|
||||||
expect((err as N8nValidationError).message).toContain('already active');
|
|
||||||
expect((err as N8nValidationError).statusCode).toBe(400);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle server error during activation', async () => {
|
|
||||||
const error = {
|
|
||||||
message: 'Request failed',
|
|
||||||
response: { status: 500, data: { message: 'Internal server error' } }
|
|
||||||
};
|
|
||||||
await mockAxiosInstance.simulateError('post', error);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.activateWorkflow('123');
|
|
||||||
expect.fail('Should have thrown an error');
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).toBeInstanceOf(N8nServerError);
|
|
||||||
expect((err as N8nServerError).message).toBe('Internal server error');
|
|
||||||
expect((err as N8nServerError).statusCode).toBe(500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('deactivateWorkflow', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
client = new N8nApiClient(defaultConfig);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should deactivate workflow successfully', async () => {
|
|
||||||
const workflow = { id: '123', name: 'Test', active: true, nodes: [], connections: {} };
|
|
||||||
const deactivatedWorkflow = { ...workflow, active: false };
|
|
||||||
mockAxiosInstance.post.mockResolvedValue({ data: deactivatedWorkflow });
|
|
||||||
|
|
||||||
const result = await client.deactivateWorkflow('123');
|
|
||||||
|
|
||||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/workflows/123/deactivate');
|
|
||||||
expect(result).toEqual(deactivatedWorkflow);
|
|
||||||
expect(result.active).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle deactivation error - workflow not found', async () => {
|
|
||||||
const error = {
|
|
||||||
message: 'Request failed',
|
|
||||||
response: { status: 404, data: { message: 'Workflow not found' } }
|
|
||||||
};
|
|
||||||
await mockAxiosInstance.simulateError('post', error);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.deactivateWorkflow('non-existent');
|
|
||||||
expect.fail('Should have thrown an error');
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).toBeInstanceOf(N8nNotFoundError);
|
|
||||||
expect((err as N8nNotFoundError).message).toContain('not found');
|
|
||||||
expect((err as N8nNotFoundError).statusCode).toBe(404);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle deactivation error - workflow already inactive', async () => {
|
|
||||||
const error = {
|
|
||||||
message: 'Request failed',
|
|
||||||
response: { status: 400, data: { message: 'Workflow is already inactive' } }
|
|
||||||
};
|
|
||||||
await mockAxiosInstance.simulateError('post', error);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.deactivateWorkflow('123');
|
|
||||||
expect.fail('Should have thrown an error');
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).toBeInstanceOf(N8nValidationError);
|
|
||||||
expect((err as N8nValidationError).message).toContain('already inactive');
|
|
||||||
expect((err as N8nValidationError).statusCode).toBe(400);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle server error during deactivation', async () => {
|
|
||||||
const error = {
|
|
||||||
message: 'Request failed',
|
|
||||||
response: { status: 500, data: { message: 'Internal server error' } }
|
|
||||||
};
|
|
||||||
await mockAxiosInstance.simulateError('post', error);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.deactivateWorkflow('123');
|
|
||||||
expect.fail('Should have thrown an error');
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).toBeInstanceOf(N8nServerError);
|
|
||||||
expect((err as N8nServerError).message).toBe('Internal server error');
|
|
||||||
expect((err as N8nServerError).statusCode).toBe(500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle authentication error during deactivation', async () => {
|
|
||||||
const error = {
|
|
||||||
message: 'Request failed',
|
|
||||||
response: { status: 401, data: { message: 'Invalid API key' } }
|
|
||||||
};
|
|
||||||
await mockAxiosInstance.simulateError('post', error);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.deactivateWorkflow('123');
|
|
||||||
expect.fail('Should have thrown an error');
|
|
||||||
} catch (err) {
|
|
||||||
expect(err).toBeInstanceOf(N8nAuthenticationError);
|
|
||||||
expect((err as N8nAuthenticationError).message).toBe('Invalid API key');
|
|
||||||
expect((err as N8nAuthenticationError).statusCode).toBe(401);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('listWorkflows', () => {
|
describe('listWorkflows', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
client = new N8nApiClient(defaultConfig);
|
client = new N8nApiClient(defaultConfig);
|
||||||
|
|||||||
@@ -380,52 +380,10 @@ describe('WorkflowDiffEngine', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const result = await diffEngine.applyDiff(baseWorkflow, request);
|
const result = await diffEngine.applyDiff(baseWorkflow, request);
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.errors![0].message).toContain('Node not found');
|
expect(result.errors![0].message).toContain('Node not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should provide helpful error when using "changes" instead of "updates" (Issue #392)', async () => {
|
|
||||||
// Simulate the common mistake of using "changes" instead of "updates"
|
|
||||||
const operation: any = {
|
|
||||||
type: 'updateNode',
|
|
||||||
nodeId: 'http-1',
|
|
||||||
changes: { // Wrong property name
|
|
||||||
'parameters.url': 'https://example.com'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const request: WorkflowDiffRequest = {
|
|
||||||
id: 'test-workflow',
|
|
||||||
operations: [operation]
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await diffEngine.applyDiff(baseWorkflow, request);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors![0].message).toContain('Invalid parameter \'changes\'');
|
|
||||||
expect(result.errors![0].message).toContain('requires \'updates\'');
|
|
||||||
expect(result.errors![0].message).toContain('Example:');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should provide helpful error when "updates" parameter is missing', async () => {
|
|
||||||
const operation: any = {
|
|
||||||
type: 'updateNode',
|
|
||||||
nodeId: 'http-1'
|
|
||||||
// Missing "updates" property
|
|
||||||
};
|
|
||||||
|
|
||||||
const request: WorkflowDiffRequest = {
|
|
||||||
id: 'test-workflow',
|
|
||||||
operations: [operation]
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await diffEngine.applyDiff(baseWorkflow, request);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors![0].message).toContain('Missing required parameter \'updates\'');
|
|
||||||
expect(result.errors![0].message).toContain('Example:');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('MoveNode Operation', () => {
|
describe('MoveNode Operation', () => {
|
||||||
@@ -4311,358 +4269,4 @@ describe('WorkflowDiffEngine', () => {
|
|||||||
expect(result.workflow.connections["When clicking 'Execute workflow'"]).toBeDefined();
|
expect(result.workflow.connections["When clicking 'Execute workflow'"]).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Workflow Activation/Deactivation Operations', () => {
|
|
||||||
it('should activate workflow with activatable trigger nodes', async () => {
|
|
||||||
// Create workflow with webhook trigger (activatable)
|
|
||||||
const workflowWithTrigger = createWorkflow('Test Workflow')
|
|
||||||
.addWebhookNode({ id: 'webhook-1', name: 'Webhook Trigger' })
|
|
||||||
.addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' })
|
|
||||||
.connect('webhook-1', 'http-1')
|
|
||||||
.build() as Workflow;
|
|
||||||
|
|
||||||
// Fix connections to use node names
|
|
||||||
const newConnections: any = {};
|
|
||||||
for (const [nodeId, outputs] of Object.entries(workflowWithTrigger.connections)) {
|
|
||||||
const node = workflowWithTrigger.nodes.find((n: any) => n.id === nodeId);
|
|
||||||
if (node) {
|
|
||||||
newConnections[node.name] = {};
|
|
||||||
for (const [outputName, connections] of Object.entries(outputs)) {
|
|
||||||
newConnections[node.name][outputName] = (connections as any[]).map((conns: any) =>
|
|
||||||
conns.map((conn: any) => {
|
|
||||||
const targetNode = workflowWithTrigger.nodes.find((n: any) => n.id === conn.node);
|
|
||||||
return { ...conn, node: targetNode ? targetNode.name : conn.node };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
workflowWithTrigger.connections = newConnections;
|
|
||||||
|
|
||||||
const operation: any = {
|
|
||||||
type: 'activateWorkflow'
|
|
||||||
};
|
|
||||||
|
|
||||||
const request: WorkflowDiffRequest = {
|
|
||||||
id: 'test-workflow',
|
|
||||||
operations: [operation]
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await diffEngine.applyDiff(workflowWithTrigger, request);
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.shouldActivate).toBe(true);
|
|
||||||
expect((result.workflow as any)._shouldActivate).toBeUndefined(); // Flag should be cleaned up
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject activation if no activatable trigger nodes', async () => {
|
|
||||||
// Create workflow with no trigger nodes at all
|
|
||||||
const workflowWithoutActivatableTrigger = createWorkflow('Test Workflow')
|
|
||||||
.addNode({
|
|
||||||
id: 'set-1',
|
|
||||||
name: 'Set Node',
|
|
||||||
type: 'n8n-nodes-base.set',
|
|
||||||
typeVersion: 1,
|
|
||||||
position: [100, 100],
|
|
||||||
parameters: {}
|
|
||||||
})
|
|
||||||
.addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' })
|
|
||||||
.connect('set-1', 'http-1')
|
|
||||||
.build() as Workflow;
|
|
||||||
|
|
||||||
// Fix connections to use node names
|
|
||||||
const newConnections: any = {};
|
|
||||||
for (const [nodeId, outputs] of Object.entries(workflowWithoutActivatableTrigger.connections)) {
|
|
||||||
const node = workflowWithoutActivatableTrigger.nodes.find((n: any) => n.id === nodeId);
|
|
||||||
if (node) {
|
|
||||||
newConnections[node.name] = {};
|
|
||||||
for (const [outputName, connections] of Object.entries(outputs)) {
|
|
||||||
newConnections[node.name][outputName] = (connections as any[]).map((conns: any) =>
|
|
||||||
conns.map((conn: any) => {
|
|
||||||
const targetNode = workflowWithoutActivatableTrigger.nodes.find((n: any) => n.id === conn.node);
|
|
||||||
return { ...conn, node: targetNode ? targetNode.name : conn.node };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
workflowWithoutActivatableTrigger.connections = newConnections;
|
|
||||||
|
|
||||||
const operation: any = {
|
|
||||||
type: 'activateWorkflow'
|
|
||||||
};
|
|
||||||
|
|
||||||
const request: WorkflowDiffRequest = {
|
|
||||||
id: 'test-workflow',
|
|
||||||
operations: [operation]
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await diffEngine.applyDiff(workflowWithoutActivatableTrigger, request);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toBeDefined();
|
|
||||||
expect(result.errors![0].message).toContain('No activatable trigger nodes found');
|
|
||||||
expect(result.errors![0].message).toContain('executeWorkflowTrigger cannot activate workflows');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject activation if all trigger nodes are disabled', async () => {
|
|
||||||
// Create workflow with disabled webhook trigger
|
|
||||||
const workflowWithDisabledTrigger = createWorkflow('Test Workflow')
|
|
||||||
.addWebhookNode({ id: 'webhook-1', name: 'Webhook Trigger', disabled: true })
|
|
||||||
.addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' })
|
|
||||||
.connect('webhook-1', 'http-1')
|
|
||||||
.build() as Workflow;
|
|
||||||
|
|
||||||
// Fix connections to use node names
|
|
||||||
const newConnections: any = {};
|
|
||||||
for (const [nodeId, outputs] of Object.entries(workflowWithDisabledTrigger.connections)) {
|
|
||||||
const node = workflowWithDisabledTrigger.nodes.find((n: any) => n.id === nodeId);
|
|
||||||
if (node) {
|
|
||||||
newConnections[node.name] = {};
|
|
||||||
for (const [outputName, connections] of Object.entries(outputs)) {
|
|
||||||
newConnections[node.name][outputName] = (connections as any[]).map((conns: any) =>
|
|
||||||
conns.map((conn: any) => {
|
|
||||||
const targetNode = workflowWithDisabledTrigger.nodes.find((n: any) => n.id === conn.node);
|
|
||||||
return { ...conn, node: targetNode ? targetNode.name : conn.node };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
workflowWithDisabledTrigger.connections = newConnections;
|
|
||||||
|
|
||||||
const operation: any = {
|
|
||||||
type: 'activateWorkflow'
|
|
||||||
};
|
|
||||||
|
|
||||||
const request: WorkflowDiffRequest = {
|
|
||||||
id: 'test-workflow',
|
|
||||||
operations: [operation]
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await diffEngine.applyDiff(workflowWithDisabledTrigger, request);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toBeDefined();
|
|
||||||
expect(result.errors![0].message).toContain('No activatable trigger nodes found');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should activate workflow with schedule trigger', async () => {
|
|
||||||
// Create workflow with schedule trigger (activatable)
|
|
||||||
const workflowWithSchedule = createWorkflow('Test Workflow')
|
|
||||||
.addNode({
|
|
||||||
id: 'schedule-1',
|
|
||||||
name: 'Schedule',
|
|
||||||
type: 'n8n-nodes-base.scheduleTrigger',
|
|
||||||
typeVersion: 1,
|
|
||||||
position: [100, 100],
|
|
||||||
parameters: { rule: { interval: [{ field: 'hours', hoursInterval: 1 }] } }
|
|
||||||
})
|
|
||||||
.addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' })
|
|
||||||
.connect('schedule-1', 'http-1')
|
|
||||||
.build() as Workflow;
|
|
||||||
|
|
||||||
// Fix connections
|
|
||||||
const newConnections: any = {};
|
|
||||||
for (const [nodeId, outputs] of Object.entries(workflowWithSchedule.connections)) {
|
|
||||||
const node = workflowWithSchedule.nodes.find((n: any) => n.id === nodeId);
|
|
||||||
if (node) {
|
|
||||||
newConnections[node.name] = {};
|
|
||||||
for (const [outputName, connections] of Object.entries(outputs)) {
|
|
||||||
newConnections[node.name][outputName] = (connections as any[]).map((conns: any) =>
|
|
||||||
conns.map((conn: any) => {
|
|
||||||
const targetNode = workflowWithSchedule.nodes.find((n: any) => n.id === conn.node);
|
|
||||||
return { ...conn, node: targetNode ? targetNode.name : conn.node };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
workflowWithSchedule.connections = newConnections;
|
|
||||||
|
|
||||||
const operation: any = {
|
|
||||||
type: 'activateWorkflow'
|
|
||||||
};
|
|
||||||
|
|
||||||
const request: WorkflowDiffRequest = {
|
|
||||||
id: 'test-workflow',
|
|
||||||
operations: [operation]
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await diffEngine.applyDiff(workflowWithSchedule, request);
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.shouldActivate).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should deactivate workflow successfully', async () => {
|
|
||||||
// Any workflow can be deactivated
|
|
||||||
const operation: any = {
|
|
||||||
type: 'deactivateWorkflow'
|
|
||||||
};
|
|
||||||
|
|
||||||
const request: WorkflowDiffRequest = {
|
|
||||||
id: 'test-workflow',
|
|
||||||
operations: [operation]
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await diffEngine.applyDiff(baseWorkflow, request);
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.shouldDeactivate).toBe(true);
|
|
||||||
expect((result.workflow as any)._shouldDeactivate).toBeUndefined(); // Flag should be cleaned up
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should deactivate workflow without trigger nodes', async () => {
|
|
||||||
// Create workflow without any trigger nodes
|
|
||||||
const workflowWithoutTrigger = createWorkflow('Test Workflow')
|
|
||||||
.addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' })
|
|
||||||
.addNode({
|
|
||||||
id: 'set-1',
|
|
||||||
name: 'Set',
|
|
||||||
type: 'n8n-nodes-base.set',
|
|
||||||
typeVersion: 1,
|
|
||||||
position: [300, 100],
|
|
||||||
parameters: {}
|
|
||||||
})
|
|
||||||
.connect('http-1', 'set-1')
|
|
||||||
.build() as Workflow;
|
|
||||||
|
|
||||||
// Fix connections
|
|
||||||
const newConnections: any = {};
|
|
||||||
for (const [nodeId, outputs] of Object.entries(workflowWithoutTrigger.connections)) {
|
|
||||||
const node = workflowWithoutTrigger.nodes.find((n: any) => n.id === nodeId);
|
|
||||||
if (node) {
|
|
||||||
newConnections[node.name] = {};
|
|
||||||
for (const [outputName, connections] of Object.entries(outputs)) {
|
|
||||||
newConnections[node.name][outputName] = (connections as any[]).map((conns: any) =>
|
|
||||||
conns.map((conn: any) => {
|
|
||||||
const targetNode = workflowWithoutTrigger.nodes.find((n: any) => n.id === conn.node);
|
|
||||||
return { ...conn, node: targetNode ? targetNode.name : conn.node };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
workflowWithoutTrigger.connections = newConnections;
|
|
||||||
|
|
||||||
const operation: any = {
|
|
||||||
type: 'deactivateWorkflow'
|
|
||||||
};
|
|
||||||
|
|
||||||
const request: WorkflowDiffRequest = {
|
|
||||||
id: 'test-workflow',
|
|
||||||
operations: [operation]
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await diffEngine.applyDiff(workflowWithoutTrigger, request);
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.shouldDeactivate).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should combine activation with other operations', async () => {
|
|
||||||
// Create workflow with webhook trigger
|
|
||||||
const workflowWithTrigger = createWorkflow('Test Workflow')
|
|
||||||
.addWebhookNode({ id: 'webhook-1', name: 'Webhook Trigger' })
|
|
||||||
.addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' })
|
|
||||||
.connect('webhook-1', 'http-1')
|
|
||||||
.build() as Workflow;
|
|
||||||
|
|
||||||
// Fix connections
|
|
||||||
const newConnections: any = {};
|
|
||||||
for (const [nodeId, outputs] of Object.entries(workflowWithTrigger.connections)) {
|
|
||||||
const node = workflowWithTrigger.nodes.find((n: any) => n.id === nodeId);
|
|
||||||
if (node) {
|
|
||||||
newConnections[node.name] = {};
|
|
||||||
for (const [outputName, connections] of Object.entries(outputs)) {
|
|
||||||
newConnections[node.name][outputName] = (connections as any[]).map((conns: any) =>
|
|
||||||
conns.map((conn: any) => {
|
|
||||||
const targetNode = workflowWithTrigger.nodes.find((n: any) => n.id === conn.node);
|
|
||||||
return { ...conn, node: targetNode ? targetNode.name : conn.node };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
workflowWithTrigger.connections = newConnections;
|
|
||||||
|
|
||||||
const operations: any[] = [
|
|
||||||
{
|
|
||||||
type: 'updateName',
|
|
||||||
name: 'Updated Workflow Name'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'addTag',
|
|
||||||
tag: 'production'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'activateWorkflow'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const request: WorkflowDiffRequest = {
|
|
||||||
id: 'test-workflow',
|
|
||||||
operations
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await diffEngine.applyDiff(workflowWithTrigger, request);
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.operationsApplied).toBe(3);
|
|
||||||
expect(result.workflow!.name).toBe('Updated Workflow Name');
|
|
||||||
expect(result.workflow!.tags).toContain('production');
|
|
||||||
expect(result.shouldActivate).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject activation if workflow has executeWorkflowTrigger only', async () => {
|
|
||||||
// Create workflow with executeWorkflowTrigger (not activatable - Issue #351)
|
|
||||||
const workflowWithExecuteTrigger = createWorkflow('Test Workflow')
|
|
||||||
.addNode({
|
|
||||||
id: 'execute-1',
|
|
||||||
name: 'Execute Workflow Trigger',
|
|
||||||
type: 'n8n-nodes-base.executeWorkflowTrigger',
|
|
||||||
typeVersion: 1,
|
|
||||||
position: [100, 100],
|
|
||||||
parameters: {}
|
|
||||||
})
|
|
||||||
.addHttpRequestNode({ id: 'http-1', name: 'HTTP Request' })
|
|
||||||
.connect('execute-1', 'http-1')
|
|
||||||
.build() as Workflow;
|
|
||||||
|
|
||||||
// Fix connections
|
|
||||||
const newConnections: any = {};
|
|
||||||
for (const [nodeId, outputs] of Object.entries(workflowWithExecuteTrigger.connections)) {
|
|
||||||
const node = workflowWithExecuteTrigger.nodes.find((n: any) => n.id === nodeId);
|
|
||||||
if (node) {
|
|
||||||
newConnections[node.name] = {};
|
|
||||||
for (const [outputName, connections] of Object.entries(outputs)) {
|
|
||||||
newConnections[node.name][outputName] = (connections as any[]).map((conns: any) =>
|
|
||||||
conns.map((conn: any) => {
|
|
||||||
const targetNode = workflowWithExecuteTrigger.nodes.find((n: any) => n.id === conn.node);
|
|
||||||
return { ...conn, node: targetNode ? targetNode.name : conn.node };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
workflowWithExecuteTrigger.connections = newConnections;
|
|
||||||
|
|
||||||
const operation: any = {
|
|
||||||
type: 'activateWorkflow'
|
|
||||||
};
|
|
||||||
|
|
||||||
const request: WorkflowDiffRequest = {
|
|
||||||
id: 'test-workflow',
|
|
||||||
operations: [operation]
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await diffEngine.applyDiff(workflowWithExecuteTrigger, request);
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.errors).toBeDefined();
|
|
||||||
expect(result.errors![0].message).toContain('No activatable trigger nodes found');
|
|
||||||
expect(result.errors![0].message).toContain('executeWorkflowTrigger cannot activate workflows');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user