mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-01-30 14:32:04 +00:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b764c6110 | ||
|
|
c3b691cedf | ||
|
|
4bf8f7006d | ||
|
|
2a9a3b9410 | ||
|
|
cd27d78bfd | ||
|
|
8d1ae278ee | ||
|
|
a84dbd6a15 | ||
|
|
1728495146 | ||
|
|
2305aaab9e | ||
|
|
f74427bdb5 | ||
|
|
fe59688e03 | ||
|
|
675989971c | ||
|
|
d875ac1e0c | ||
|
|
5bf1bc46e9 | ||
|
|
3bab53a3be | ||
|
|
8ffda534be | ||
|
|
0bf0e1cd74 | ||
|
|
9fb847a16f | ||
|
|
bf999232a3 | ||
|
|
59e476fdf0 | ||
|
|
711cecb90d | ||
|
|
582c9aac53 | ||
|
|
997cc93a0a | ||
|
|
2f234780dd | ||
|
|
99518f71cf | ||
|
|
fe1e3640af | ||
|
|
aef9d983e2 | ||
|
|
e252a36e3f | ||
|
|
39e13c451f | ||
|
|
a8e0b1ed34 | ||
|
|
ed7de10fd2 | ||
|
|
b7fa12667b | ||
|
|
4854a50854 | ||
|
|
cb5691f17d | ||
|
|
6d45ff8bcb | ||
|
|
64b9cf47a7 | ||
|
|
f4dff6b8e1 | ||
|
|
ec0d2e8a6e | ||
|
|
a1db133a50 | ||
|
|
d8bab6e667 |
34
.env.example
34
.env.example
@@ -132,4 +132,36 @@ ENABLE_MULTI_TENANT=false
|
||||
|
||||
# Enable metadata generation during template fetch (default: false)
|
||||
# Set to true to automatically generate metadata when running fetch:templates
|
||||
# METADATA_GENERATION_ENABLED=false
|
||||
# METADATA_GENERATION_ENABLED=false
|
||||
|
||||
# ========================================
|
||||
# INTEGRATION TESTING CONFIGURATION
|
||||
# ========================================
|
||||
# Configuration for integration tests that call real n8n instance API
|
||||
|
||||
# n8n API Configuration for Integration Tests
|
||||
# For local development: Use your local n8n instance
|
||||
# For CI: These will be provided by GitHub secrets
|
||||
# N8N_API_URL=http://localhost:5678
|
||||
# N8N_API_KEY=
|
||||
|
||||
# Pre-activated Webhook Workflows for Testing
|
||||
# These workflows must be created manually in n8n and activated
|
||||
# because n8n API doesn't support workflow activation.
|
||||
#
|
||||
# Setup Instructions:
|
||||
# 1. Create 4 workflows in n8n UI (one for each HTTP method)
|
||||
# 2. Each workflow should have a single Webhook node
|
||||
# 3. Configure webhook paths: mcp-test-get, mcp-test-post, mcp-test-put, mcp-test-delete
|
||||
# 4. ACTIVATE each workflow in n8n UI
|
||||
# 5. Copy the workflow IDs here
|
||||
#
|
||||
# N8N_TEST_WEBHOOK_GET_ID= # Workflow ID for GET method webhook
|
||||
# N8N_TEST_WEBHOOK_POST_ID= # Workflow ID for POST method webhook
|
||||
# N8N_TEST_WEBHOOK_PUT_ID= # Workflow ID for PUT method webhook
|
||||
# N8N_TEST_WEBHOOK_DELETE_ID= # Workflow ID for DELETE method webhook
|
||||
|
||||
# Test Configuration
|
||||
N8N_TEST_CLEANUP_ENABLED=true # Enable automatic cleanup of test workflows
|
||||
N8N_TEST_TAG=mcp-integration-test # Tag applied to all test workflows
|
||||
N8N_TEST_NAME_PREFIX=[MCP-TEST] # Name prefix for test workflows
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -93,6 +93,9 @@ tmp/
|
||||
docs/batch_*.jsonl
|
||||
**/batch_*_error.jsonl
|
||||
|
||||
# Local documentation and analysis files
|
||||
docs/local/
|
||||
|
||||
# Database files
|
||||
# Database files - nodes.db is now tracked directly
|
||||
# data/*.db
|
||||
|
||||
347
CHANGELOG.md
347
CHANGELOG.md
@@ -5,6 +5,353 @@ 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/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.15.3] - 2025-10-03
|
||||
|
||||
### Added
|
||||
- **Error Message Capture in Telemetry** - Enhanced telemetry tracking to capture actual error messages for better debugging
|
||||
- Added optional `errorMessage` parameter to `trackError()` method
|
||||
- Comprehensive error message sanitization to protect sensitive data
|
||||
- Updated all production and test call sites to pass error messages
|
||||
- Error messages now stored in telemetry events table for analysis
|
||||
|
||||
### Security
|
||||
- **Enhanced Error Message Sanitization** - Comprehensive security hardening for telemetry data
|
||||
- **ReDoS Prevention**: Early truncation to 1500 chars before regex processing
|
||||
- **Full URL Redaction**: Changed from `[URL]/path` to `[URL]` to prevent API structure leakage
|
||||
- **Correct Sanitization Order**: URLs → specific credentials → emails → generic patterns
|
||||
- **Credential Pattern Detection**: Added AWS keys, GitHub tokens, JWT, Bearer tokens
|
||||
- **Error Handling**: Try-catch wrapper with `[SANITIZATION_FAILED]` fallback
|
||||
- **Stack Trace Truncation**: Limited to first 3 lines to reduce attack surface
|
||||
|
||||
### Fixed
|
||||
- **Missing Error Messages**: Resolved issue where 272+ weekly validation errors had no error messages captured
|
||||
- **Data Leakage**: Fixed URL path preservation exposing API versions and user IDs
|
||||
- **Email Exposure**: Fixed sanitization order allowing emails in URLs to leak
|
||||
- **ReDoS Vulnerability**: Removed complex capturing regex patterns that could cause performance issues
|
||||
|
||||
### Changed
|
||||
- **Breaking Change**: `trackError()` signature updated with 4th parameter `errorMessage?: string`
|
||||
- All internal call sites updated in single commit (atomic change)
|
||||
- Not backwards compatible but acceptable as all code is internal
|
||||
|
||||
### Technical Details
|
||||
- **Sanitization Patterns**:
|
||||
- AWS Keys: `AKIA[A-Z0-9]{16}` → `[AWS_KEY]`
|
||||
- GitHub Tokens: `ghp_[a-zA-Z0-9]{36,}` → `[GITHUB_TOKEN]`
|
||||
- JWT: `eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+` → `[JWT]`
|
||||
- Bearer Tokens: `Bearer [^\s]+` → `Bearer [TOKEN]`
|
||||
- Emails: `[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}` → `[EMAIL]`
|
||||
- Long Keys: `\b[a-zA-Z0-9_-]{32,}\b` → `[KEY]`
|
||||
- Generic Credentials: `password/api_key/token=<value>` → `<field>=[REDACTED]`
|
||||
|
||||
### Test Coverage
|
||||
- Added 18 new security-focused tests
|
||||
- Total telemetry tests: 269 passing
|
||||
- Coverage: 90.75% for telemetry module
|
||||
- All security patterns validated with edge cases
|
||||
|
||||
### Performance
|
||||
- Early truncation prevents ReDoS attacks
|
||||
- Simplified regex patterns (no complex capturing groups)
|
||||
- Sanitization adds <1ms overhead per error
|
||||
- Final message truncated to 500 chars max
|
||||
|
||||
### Impact
|
||||
- **Debugging**: Error messages now available for root cause analysis
|
||||
- **Security**: Comprehensive protection against credential leakage
|
||||
- **Performance**: Protected against ReDoS attacks
|
||||
- **Reliability**: Try-catch ensures sanitization never breaks telemetry
|
||||
|
||||
## [2.15.2] - 2025-10-03
|
||||
|
||||
### Fixed
|
||||
- **Template Search Performance & Reliability** - Enhanced `search_templates_by_metadata` with production-ready improvements
|
||||
- **Ordering Stability**: Implemented CTE with VALUES clause to preserve exact Phase 1 ordering
|
||||
- Prevents ordering discrepancies between ID selection and data fetch phases
|
||||
- Ensures deterministic results across query phases
|
||||
- **Defensive ID Validation**: Added type safety filters before Phase 2 query
|
||||
- Validates only positive integers are used in the CTE
|
||||
- Logs warnings for filtered invalid IDs
|
||||
- **Performance Monitoring**: Added detailed timing metrics (phase1Ms, phase2Ms, totalMs)
|
||||
- Enables quantifying optimization benefits
|
||||
- Debug logging for all search operations
|
||||
- **DRY Refactoring**: Extracted `buildMetadataFilterConditions` helper method
|
||||
- Eliminates duplication between `searchTemplatesByMetadata` and `getMetadataSearchCount`
|
||||
- Centralized filter-building logic
|
||||
|
||||
### Added
|
||||
- **Comprehensive Test Coverage** - 31 new unit tests achieving 100% coverage for changed code
|
||||
- `buildMetadataFilterConditions` - All filter combinations (11 tests)
|
||||
- Performance logging validation (3 tests)
|
||||
- ID filtering edge cases - negative, zero, non-integer, null (7 tests)
|
||||
- `getMetadataSearchCount` - Shared helper usage (7 tests)
|
||||
- Two-phase query optimization verification (3 tests)
|
||||
- Fixed flaky integration tests with deterministic ordering using unique view counts
|
||||
|
||||
### Performance
|
||||
- Query optimization maintains sub-1ms Phase 1 performance
|
||||
- Two-phase approach prevents timeout on large template sets
|
||||
- CTE-based ordering adds negligible overhead (<1ms)
|
||||
|
||||
### Test Results
|
||||
- Unit tests: 31 new tests, all passing
|
||||
- Integration tests: 36 passing, 1 skipped
|
||||
- **Coverage**: 100% for changed code (previously 36.58% patch coverage)
|
||||
|
||||
## [2.15.0] - 2025-10-02
|
||||
|
||||
### 🚀 Major Features
|
||||
|
||||
#### P0-R3: Pre-extracted Template Configurations
|
||||
- **Template-Based Configuration System** - 2,646 real-world node configurations from popular templates
|
||||
- Pre-extracted node configurations from all workflow templates
|
||||
- Ranked by template popularity (views)
|
||||
- Includes metadata: complexity, use cases, credentials, expressions
|
||||
- Query performance: <1ms (vs 30-60ms with previous system)
|
||||
- Database size increase: ~513 KB for 2,000+ configurations
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
#### Removed: `get_node_for_task` Tool
|
||||
- **Reason**: Only 31 hardcoded tasks, 28% failure rate in production
|
||||
- **Replacement**: Template-based examples with 2,646 real configurations
|
||||
|
||||
#### Migration Guide
|
||||
|
||||
**Before (v2.14.7):**
|
||||
```javascript
|
||||
// Get configuration for a task
|
||||
get_node_for_task({ task: "receive_webhook" })
|
||||
```
|
||||
|
||||
**After (v2.15.0):**
|
||||
```javascript
|
||||
// Option 1: Search nodes with examples
|
||||
search_nodes({
|
||||
query: "webhook",
|
||||
includeExamples: true
|
||||
})
|
||||
// Returns: Top 2 real template configs per node
|
||||
|
||||
// Option 2: Get node essentials with examples
|
||||
get_node_essentials({
|
||||
nodeType: "nodes-base.webhook",
|
||||
includeExamples: true
|
||||
})
|
||||
// Returns: Top 3 real template configs with full metadata
|
||||
```
|
||||
|
||||
### Added
|
||||
|
||||
- **Enhanced `search_nodes` Tool**
|
||||
- New parameter: `includeExamples` (boolean, default: false)
|
||||
- Returns top 2 real-world configurations per node from popular templates
|
||||
- Includes: configuration, template name, view count
|
||||
|
||||
- **Enhanced `get_node_essentials` Tool**
|
||||
- New parameter: `includeExamples` (boolean, default: false)
|
||||
- Returns top 3 real-world configurations with full metadata
|
||||
- Includes: configuration, source template, complexity, use cases, credentials info
|
||||
|
||||
- **Database Schema**
|
||||
- New table: `template_node_configs` - Pre-extracted node configurations
|
||||
- New view: `ranked_node_configs` - Easy access to top 5 configs per node
|
||||
- Optimized indexes for fast queries (<1ms)
|
||||
|
||||
- **Template Processing**
|
||||
- Automatic config extraction during `npm run fetch:templates`
|
||||
- Standalone extraction mode: `npm run fetch:templates:extract`
|
||||
- Expression detection ({{...}}, $json, $node)
|
||||
- Complexity analysis and use case extraction
|
||||
- Ranking by template popularity
|
||||
- Auto-creates `template_node_configs` table if missing
|
||||
|
||||
- **Comprehensive Test Suite**
|
||||
- 85+ tests covering all aspects of template configuration system
|
||||
- Integration tests for database operations and end-to-end workflows
|
||||
- Unit tests for tool parameters, extraction logic, and ranking algorithm
|
||||
- Fixtures for consistent test data across test suites
|
||||
- Test documentation in P0-R3-TEST-PLAN.md
|
||||
|
||||
### Removed
|
||||
|
||||
- Tool: `get_node_for_task` (see Breaking Changes above)
|
||||
- Tool documentation: `get-node-for-task.ts`
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`search_nodes` includeExamples Support**
|
||||
- Fixed `includeExamples` parameter not working due to missing FTS5 table
|
||||
- Added example support to `searchNodesLIKE` fallback method
|
||||
- Now returns template-based examples in all search scenarios
|
||||
- Affects 100% of search_nodes calls (database lacks nodes_fts table)
|
||||
|
||||
### Deprecated
|
||||
|
||||
- `TaskTemplates` service marked for removal in v2.16.0
|
||||
- `list_tasks` tool marked for deprecation (use template search instead)
|
||||
|
||||
### Performance
|
||||
|
||||
- Query time: <1ms for pre-extracted configs (vs 30-60ms for on-demand generation)
|
||||
- 30-60x faster configuration lookups
|
||||
- 85x more configuration examples (2,646 vs 31)
|
||||
|
||||
## [2.14.7] - 2025-10-02
|
||||
|
||||
### Fixed
|
||||
- **Issue #248: Settings Validation Error** - Fixed "settings must NOT have additional properties" API errors
|
||||
- Added `callerPolicy` property to `workflowSettingsSchema` to support valid n8n workflow setting
|
||||
- Implemented whitelist-based settings filtering in `cleanWorkflowForUpdate()` to prevent API errors
|
||||
- Filter removes UI-only properties (e.g., `timeSavedPerExecution`) that cause validation failures
|
||||
- Only whitelisted properties are sent to n8n API: `executionOrder`, `timezone`, `saveDataErrorExecution`, `saveDataSuccessExecution`, `saveManualExecutions`, `saveExecutionProgress`, `executionTimeout`, `errorWorkflow`, `callerPolicy`
|
||||
- Resolves workflow update failures caused by workflows fetched from n8n containing non-standard properties
|
||||
- Added 6 comprehensive unit tests covering settings filtering scenarios
|
||||
|
||||
- **Issue #249: Misleading AddConnection Error Messages** - Enhanced parameter validation with helpful error messages
|
||||
- Detect common parameter mistakes: using `sourceNodeId`/`targetNodeId` instead of correct `source`/`target`
|
||||
- Improved error messages include:
|
||||
- Identification of wrong parameter names with correction guidance
|
||||
- Examples of correct usage
|
||||
- List of available nodes when source/target not found
|
||||
- Error messages now actionable instead of cryptic (was: "Source node not found: undefined")
|
||||
- Added 8 comprehensive unit tests for parameter validation scenarios
|
||||
|
||||
- **P0-R1: Universal Node Type Normalization** - Eliminates 80% of validation errors
|
||||
- Implemented `NodeTypeNormalizer` utility for consistent node type handling
|
||||
- Automatically converts short forms to full forms (e.g., `nodes-base.webhook` → `n8n-nodes-base.webhook`)
|
||||
- Applied normalization across all workflow validation entry points
|
||||
- Updated workflow validator, handlers, and repository for universal normalization
|
||||
- Fixed test expectations to match normalized node type format
|
||||
- Resolves the single largest source of validation errors in production
|
||||
|
||||
### Added
|
||||
- `NodeTypeNormalizer` utility class for universal node type normalization
|
||||
- `normalizeToFullForm()` - Convert any node type variation to canonical form
|
||||
- `normalizeWithDetails()` - Get normalization result with metadata
|
||||
- `normalizeWorkflowNodeTypes()` - Batch normalize all nodes in a workflow
|
||||
- Settings whitelist filtering in `cleanWorkflowForUpdate()` with comprehensive null-safety
|
||||
- Enhanced `validateAddConnection()` with proactive parameter validation
|
||||
- 14 new unit tests for issues #248 and #249 fixes
|
||||
|
||||
### Changed
|
||||
- Node repository now uses `NodeTypeNormalizer` for all lookups
|
||||
- Workflow validation applies normalization before structure checks
|
||||
- Workflow diff engine validates connection parameters before processing
|
||||
- Settings filtering applied to all workflow update operations
|
||||
|
||||
### Performance
|
||||
- No performance impact - normalization adds <1ms overhead per workflow
|
||||
- Settings filtering is O(9) - negligible impact
|
||||
|
||||
### Test Coverage
|
||||
- n8n-validation tests: 73/73 passing (100% coverage)
|
||||
- workflow-diff-engine tests: 110/110 passing (89.72% coverage)
|
||||
- Total: 183 tests passing
|
||||
|
||||
### Impact
|
||||
- **Issue #248**: Eliminates ALL settings validation errors for workflows with non-standard properties
|
||||
- **Issue #249**: Provides clear, actionable error messages reducing user frustration
|
||||
- **P0-R1**: Reduces validation error rate by 80% (addresses 4,800+ weekly errors)
|
||||
- Combined impact: Expected overall error rate reduction from 5-10% to <2%
|
||||
|
||||
## [2.14.6] - 2025-10-01
|
||||
|
||||
### Enhanced
|
||||
- **Webhook Error Messages**: Replaced generic "Please try again later or contact support" messages with actionable guidance
|
||||
- Error messages now extract execution ID and workflow ID from failed webhook triggers
|
||||
- Guide users to use `n8n_get_execution({id: executionId, mode: 'preview'})` for efficient debugging
|
||||
- Format: "Workflow {workflowId} execution {executionId} failed. Use n8n_get_execution({id: '{executionId}', mode: 'preview'}) to investigate the error."
|
||||
- When no execution ID available: "Workflow failed to execute. Use n8n_list_executions to find recent executions, then n8n_get_execution with mode='preview' to investigate."
|
||||
|
||||
### Added
|
||||
- New error formatting functions in `n8n-errors.ts`:
|
||||
- `formatExecutionError()` - Creates execution-specific error messages with debugging guidance
|
||||
- `formatNoExecutionError()` - Provides guidance when execution context unavailable
|
||||
- Enhanced `McpToolResponse` type with optional `executionId` and `workflowId` fields
|
||||
- Error handling documentation in `n8n-trigger-webhook-workflow` tool docs
|
||||
- 30 new comprehensive tests for error message formatting and webhook error handling
|
||||
|
||||
### Changed
|
||||
- `handleTriggerWebhookWorkflow` now extracts execution context from error responses
|
||||
- `getUserFriendlyErrorMessage` returns actual server error messages instead of generic text
|
||||
- Tool documentation type enhanced with optional `errorHandling` field
|
||||
|
||||
### Fixed
|
||||
- Test expectations updated to match new error message format (handlers-workflow-diff.test.ts)
|
||||
|
||||
### Benefits
|
||||
- **Fast debugging**: Preview mode executes in <50ms (vs seconds for full data)
|
||||
- **Efficient**: Uses ~500 tokens (vs 50K+ tokens for full execution data)
|
||||
- **Safe**: No timeout or token limit risks
|
||||
- **Actionable**: Clear next steps for users to investigate failures
|
||||
|
||||
### Impact
|
||||
- Eliminates unhelpful "contact support" messages
|
||||
- Provides specific, actionable debugging guidance
|
||||
- Reduces debugging time by directing users to efficient tools
|
||||
- 100% backward compatible - only improves error messages
|
||||
|
||||
## [2.14.5] - 2025-09-30
|
||||
|
||||
### Added
|
||||
- **Intelligent Execution Data Filtering**: Major enhancement to `n8n_get_execution` tool to handle large datasets without exceeding token limits
|
||||
- **Preview Mode**: Shows data structure, counts, and size estimates without actual data (~500 tokens)
|
||||
- **Summary Mode**: Returns 2 sample items per node (safe default, ~2-5K tokens)
|
||||
- **Filtered Mode**: Granular control with node filtering and custom item limits
|
||||
- **Full Mode**: Complete data retrieval (explicit opt-in)
|
||||
- Smart recommendations based on data size (guides optimal retrieval strategy)
|
||||
- Structure-only mode (`itemsLimit: 0`) to see data schema without values
|
||||
- Node-specific filtering with `nodeNames` parameter
|
||||
- Input data inclusion option for debugging transformations
|
||||
- Automatic size estimation and token consumption guidance
|
||||
|
||||
### Enhanced
|
||||
- `n8n_get_execution` tool with new parameters:
|
||||
- `mode`: 'preview' | 'summary' | 'filtered' | 'full'
|
||||
- `nodeNames`: Filter to specific nodes
|
||||
- `itemsLimit`: Control items per node (0=structure, -1=unlimited, default=2)
|
||||
- `includeInputData`: Include input data for debugging
|
||||
- Legacy `includeData` parameter mapped to new modes for backward compatibility
|
||||
- Tool documentation with comprehensive examples and best practices
|
||||
- Type system with new interfaces: `ExecutionMode`, `ExecutionPreview`, `ExecutionFilterOptions`, `FilteredExecutionResponse`
|
||||
|
||||
### Technical Improvements
|
||||
- New `ExecutionProcessor` service with intelligent filtering logic
|
||||
- Smart data truncation with metadata (`hasMoreData`, `truncated` flags)
|
||||
- Validation for `itemsLimit` (capped at 1000, negative values default to 2)
|
||||
- Error message extraction helper for consistent error handling
|
||||
- Constants-based thresholds for easy tuning (20/50/100KB limits)
|
||||
- 33 comprehensive unit tests with 78% coverage
|
||||
- Null-safe data access throughout
|
||||
|
||||
### Performance
|
||||
- Preview mode: <50ms (no data, just structure)
|
||||
- Summary mode: <200ms (2 items per node)
|
||||
- Filtered mode: 50-500ms (depends on filters)
|
||||
- Size estimation within 10-20% accuracy
|
||||
|
||||
### Impact
|
||||
- Solves token limit issues when inspecting large workflow executions
|
||||
- Enables AI agents to understand execution data without overwhelming responses
|
||||
- Reduces token usage by 80-95% for large datasets (50+ items)
|
||||
- Maintains 100% backward compatibility with existing integrations
|
||||
- Recommended workflow: preview → recommendation → filtered/summary
|
||||
|
||||
### Fixed
|
||||
- Preview mode bug: Fixed API data fetching logic to ensure preview mode retrieves execution data for structure analysis and recommendation generation
|
||||
- Changed `fetchFullData` condition in handlers-n8n-manager.ts to include preview mode
|
||||
- Preview mode now correctly returns structure, item counts, and size estimates
|
||||
- Recommendations are now accurate and prevent token overflow issues
|
||||
|
||||
### Migration Guide
|
||||
- **No breaking changes**: Existing `n8n_get_execution` calls work unchanged
|
||||
- New recommended workflow:
|
||||
1. Call with `mode: 'preview'` to assess data size
|
||||
2. Follow `recommendation.suggestedMode` from preview
|
||||
3. Use `mode: 'filtered'` with `itemsLimit` for precise control
|
||||
- Legacy `includeData: true` now maps to `mode: 'summary'` (safer default)
|
||||
|
||||
## [2.14.4] - 2025-09-30
|
||||
|
||||
### Added
|
||||
|
||||
484
P0-R3-TEST-PLAN.md
Normal file
484
P0-R3-TEST-PLAN.md
Normal file
@@ -0,0 +1,484 @@
|
||||
# P0-R3 Feature Test Coverage Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document outlines comprehensive test coverage for the P0-R3 feature (Template-based Configuration Examples). The feature adds real-world configuration examples from popular templates to node search and essentials tools.
|
||||
|
||||
**Feature Overview:**
|
||||
- New database table: `template_node_configs` (197 pre-extracted configurations)
|
||||
- Enhanced tools: `search_nodes({includeExamples: true})` and `get_node_essentials({includeExamples: true})`
|
||||
- Breaking changes: Removed `get_node_for_task` tool
|
||||
|
||||
## Test Files Created
|
||||
|
||||
### Unit Tests
|
||||
|
||||
#### 1. `/tests/unit/scripts/fetch-templates-extraction.test.ts` ✅
|
||||
**Purpose:** Test template extraction logic from `fetch-templates.ts`
|
||||
|
||||
**Coverage:**
|
||||
- `extractNodeConfigs()` - 90%+ coverage
|
||||
- Valid workflows with multiple nodes
|
||||
- Empty workflows
|
||||
- Malformed compressed data
|
||||
- Invalid JSON
|
||||
- Nodes without parameters
|
||||
- Sticky note filtering
|
||||
- Credential handling
|
||||
- Expression detection
|
||||
- Special characters
|
||||
- Large workflows (100 nodes)
|
||||
|
||||
- `detectExpressions()` - 100% coverage
|
||||
- `={{...}}` syntax detection
|
||||
- `$json` references
|
||||
- `$node` references
|
||||
- Nested objects
|
||||
- Arrays
|
||||
- Null/undefined handling
|
||||
- Multiple expression types
|
||||
|
||||
**Test Count:** 27 tests
|
||||
**Expected Coverage:** 92%+
|
||||
|
||||
---
|
||||
|
||||
#### 2. `/tests/unit/mcp/search-nodes-examples.test.ts` ✅
|
||||
**Purpose:** Test `search_nodes` tool with includeExamples parameter
|
||||
|
||||
**Coverage:**
|
||||
- includeExamples parameter behavior
|
||||
- false: no examples returned
|
||||
- undefined: no examples returned (default)
|
||||
- true: examples returned
|
||||
- Example data structure validation
|
||||
- Top 2 limit enforcement
|
||||
- Backward compatibility
|
||||
- Performance (<100ms)
|
||||
- Error handling (malformed JSON, database errors)
|
||||
- searchNodesLIKE integration
|
||||
- searchNodesFTS integration
|
||||
|
||||
**Test Count:** 12 tests
|
||||
**Expected Coverage:** 85%+
|
||||
|
||||
---
|
||||
|
||||
#### 3. `/tests/unit/mcp/get-node-essentials-examples.test.ts` ✅
|
||||
**Purpose:** Test `get_node_essentials` tool with includeExamples parameter
|
||||
|
||||
**Coverage:**
|
||||
- includeExamples parameter behavior
|
||||
- Full metadata structure
|
||||
- configuration object
|
||||
- source (template, views, complexity)
|
||||
- useCases (limited to 2)
|
||||
- metadata (hasCredentials, hasExpressions)
|
||||
- Cache key differentiation
|
||||
- Backward compatibility
|
||||
- Performance (<100ms)
|
||||
- Error handling
|
||||
- Top 3 limit enforcement
|
||||
|
||||
**Test Count:** 13 tests
|
||||
**Expected Coverage:** 88%+
|
||||
|
||||
---
|
||||
|
||||
### Integration Tests
|
||||
|
||||
#### 4. `/tests/integration/database/template-node-configs.test.ts` ✅
|
||||
**Purpose:** Test database schema, migrations, and operations
|
||||
|
||||
**Coverage:**
|
||||
- Schema validation
|
||||
- Table creation
|
||||
- All columns present
|
||||
- Correct types and constraints
|
||||
- CHECK constraint on complexity
|
||||
- Indexes
|
||||
- idx_config_node_type_rank
|
||||
- idx_config_complexity
|
||||
- idx_config_auth
|
||||
- View: ranked_node_configs
|
||||
- Top 5 per node_type
|
||||
- Correct ordering
|
||||
- Foreign key constraints
|
||||
- CASCADE delete
|
||||
- Referential integrity
|
||||
- Data operations
|
||||
- INSERT with all fields
|
||||
- Nullable fields
|
||||
- Rank updates
|
||||
- Delete rank > 10
|
||||
- Performance
|
||||
- 1000 records < 10ms queries
|
||||
- Migration idempotency
|
||||
|
||||
**Test Count:** 19 tests
|
||||
**Expected Coverage:** 95%+
|
||||
|
||||
---
|
||||
|
||||
#### 5. `/tests/integration/mcp/template-examples-e2e.test.ts` ✅
|
||||
**Purpose:** End-to-end integration testing
|
||||
|
||||
**Coverage:**
|
||||
- Direct SQL queries
|
||||
- Top 2 examples for search_nodes
|
||||
- Top 3 examples with metadata for get_node_essentials
|
||||
- Data structure validation
|
||||
- Valid JSON in all fields
|
||||
- Credentials when has_credentials=1
|
||||
- Ranked view functionality
|
||||
- Performance with 100+ configs
|
||||
- Query performance < 5ms
|
||||
- Complexity filtering
|
||||
- Edge cases
|
||||
- Non-existent node types
|
||||
- Long parameters_json (100 params)
|
||||
- Special characters (Unicode, emojis, symbols)
|
||||
- Data integrity
|
||||
- Foreign key constraints
|
||||
- Cascade deletes
|
||||
|
||||
**Test Count:** 14 tests
|
||||
**Expected Coverage:** 90%+
|
||||
|
||||
---
|
||||
|
||||
### Test Fixtures
|
||||
|
||||
#### 6. `/tests/fixtures/template-configs.ts` ✅
|
||||
**Purpose:** Reusable test data
|
||||
|
||||
**Provides:**
|
||||
- `sampleConfigs`: 7 realistic node configurations
|
||||
- simpleWebhook
|
||||
- webhookWithAuth
|
||||
- httpRequestBasic
|
||||
- httpRequestWithExpressions
|
||||
- slackMessage
|
||||
- codeNodeTransform
|
||||
- codeNodeWithExpressions
|
||||
|
||||
- `sampleWorkflows`: 3 complete workflows
|
||||
- webhookToSlack
|
||||
- apiWorkflow
|
||||
- complexWorkflow
|
||||
|
||||
- **Helper Functions:**
|
||||
- `compressWorkflow()` - Compress to base64
|
||||
- `createTemplateMetadata()` - Generate metadata
|
||||
- `createConfigBatch()` - Batch create configs
|
||||
- `getConfigByComplexity()` - Filter by complexity
|
||||
- `getConfigsWithExpressions()` - Filter with expressions
|
||||
- `getConfigsWithCredentials()` - Filter with credentials
|
||||
- `createInsertStatement()` - SQL insert helper
|
||||
|
||||
---
|
||||
|
||||
## Existing Tests Requiring Updates
|
||||
|
||||
### High Priority
|
||||
|
||||
#### 1. `tests/unit/mcp/parameter-validation.test.ts`
|
||||
**Line 480:** Remove `get_node_for_task` from legacyValidationTools array
|
||||
|
||||
```typescript
|
||||
// REMOVE THIS:
|
||||
{ name: 'get_node_for_task', args: {}, expected: 'Missing required parameters for get_node_for_task: task' },
|
||||
```
|
||||
|
||||
**Status:** ⚠️ BREAKING CHANGE - Tool removed
|
||||
|
||||
---
|
||||
|
||||
#### 2. `tests/unit/mcp/tools.test.ts`
|
||||
**Update:** Remove `get_node_for_task` from templates category
|
||||
|
||||
```typescript
|
||||
// BEFORE:
|
||||
templates: ['list_tasks', 'get_node_for_task', 'search_templates', ...]
|
||||
|
||||
// AFTER:
|
||||
templates: ['list_tasks', 'search_templates', ...]
|
||||
```
|
||||
|
||||
**Add:** Tests for new includeExamples parameter in tool definitions
|
||||
|
||||
```typescript
|
||||
it('should have includeExamples parameter in search_nodes', () => {
|
||||
const searchNodesTool = tools.find(t => t.name === 'search_nodes');
|
||||
expect(searchNodesTool.inputSchema.properties.includeExamples).toBeDefined();
|
||||
expect(searchNodesTool.inputSchema.properties.includeExamples.type).toBe('boolean');
|
||||
expect(searchNodesTool.inputSchema.properties.includeExamples.default).toBe(false);
|
||||
});
|
||||
|
||||
it('should have includeExamples parameter in get_node_essentials', () => {
|
||||
const essentialsTool = tools.find(t => t.name === 'get_node_essentials');
|
||||
expect(essentialsTool.inputSchema.properties.includeExamples).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
**Status:** ⚠️ REQUIRED UPDATE
|
||||
|
||||
---
|
||||
|
||||
#### 3. `tests/integration/mcp-protocol/session-management.test.ts`
|
||||
**Remove:** Test case calling `get_node_for_task` with invalid task
|
||||
|
||||
```typescript
|
||||
// REMOVE THIS TEST:
|
||||
client.callTool({ name: 'get_node_for_task', arguments: { task: 'invalid_task' } }).catch(e => e)
|
||||
```
|
||||
|
||||
**Status:** ⚠️ BREAKING CHANGE
|
||||
|
||||
---
|
||||
|
||||
#### 4. `tests/integration/mcp-protocol/tool-invocation.test.ts`
|
||||
**Remove:** Entire `get_node_for_task` describe block
|
||||
|
||||
**Add:** Tests for new includeExamples functionality
|
||||
|
||||
```typescript
|
||||
describe('search_nodes with includeExamples', () => {
|
||||
it('should return examples when includeExamples is true', async () => {
|
||||
const response = await client.callTool({
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'webhook', includeExamples: true }
|
||||
});
|
||||
|
||||
expect(response.results).toBeDefined();
|
||||
// Examples may or may not be present depending on database
|
||||
});
|
||||
|
||||
it('should not return examples when includeExamples is false', async () => {
|
||||
const response = await client.callTool({
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'webhook', includeExamples: false }
|
||||
});
|
||||
|
||||
expect(response.results).toBeDefined();
|
||||
response.results.forEach(node => {
|
||||
expect(node.examples).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('get_node_essentials with includeExamples', () => {
|
||||
it('should return examples with metadata when includeExamples is true', async () => {
|
||||
const response = await client.callTool({
|
||||
name: 'get_node_essentials',
|
||||
arguments: { nodeType: 'nodes-base.webhook', includeExamples: true }
|
||||
});
|
||||
|
||||
expect(response.nodeType).toBeDefined();
|
||||
// Examples may or may not be present depending on database
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Status:** ⚠️ REQUIRED UPDATE
|
||||
|
||||
---
|
||||
|
||||
### Medium Priority
|
||||
|
||||
#### 5. `tests/unit/services/task-templates.test.ts`
|
||||
**Status:** ✅ No changes needed (TaskTemplates marked as deprecated but not removed)
|
||||
|
||||
**Note:** TaskTemplates remains for backward compatibility. Tests should continue to pass.
|
||||
|
||||
---
|
||||
|
||||
## Test Execution Plan
|
||||
|
||||
### Phase 1: Unit Tests
|
||||
```bash
|
||||
# Run new unit tests
|
||||
npm test tests/unit/scripts/fetch-templates-extraction.test.ts
|
||||
npm test tests/unit/mcp/search-nodes-examples.test.ts
|
||||
npm test tests/unit/mcp/get-node-essentials-examples.test.ts
|
||||
|
||||
# Expected: All pass, 52 tests
|
||||
```
|
||||
|
||||
### Phase 2: Integration Tests
|
||||
```bash
|
||||
# Run new integration tests
|
||||
npm test tests/integration/database/template-node-configs.test.ts
|
||||
npm test tests/integration/mcp/template-examples-e2e.test.ts
|
||||
|
||||
# Expected: All pass, 33 tests
|
||||
```
|
||||
|
||||
### Phase 3: Update Existing Tests
|
||||
```bash
|
||||
# Update files as outlined above, then run:
|
||||
npm test tests/unit/mcp/parameter-validation.test.ts
|
||||
npm test tests/unit/mcp/tools.test.ts
|
||||
npm test tests/integration/mcp-protocol/session-management.test.ts
|
||||
npm test tests/integration/mcp-protocol/tool-invocation.test.ts
|
||||
|
||||
# Expected: All pass after updates
|
||||
```
|
||||
|
||||
### Phase 4: Full Test Suite
|
||||
```bash
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Run with coverage
|
||||
npm run test:coverage
|
||||
|
||||
# Expected coverage improvements:
|
||||
# - src/scripts/fetch-templates.ts: +20% (60% → 80%)
|
||||
# - src/mcp/server.ts: +5% (75% → 80%)
|
||||
# - Overall project: +2% (current → current+2%)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coverage Expectations
|
||||
|
||||
### New Code Coverage
|
||||
|
||||
| File | Function | Target | Tests |
|
||||
|------|----------|--------|-------|
|
||||
| fetch-templates.ts | extractNodeConfigs | 95% | 15 tests |
|
||||
| fetch-templates.ts | detectExpressions | 100% | 12 tests |
|
||||
| server.ts | searchNodes (with examples) | 90% | 8 tests |
|
||||
| server.ts | getNodeEssentials (with examples) | 90% | 10 tests |
|
||||
| Database migration | template_node_configs | 100% | 19 tests |
|
||||
|
||||
### Overall Coverage Goals
|
||||
|
||||
- **Unit Tests:** 90%+ coverage for new code
|
||||
- **Integration Tests:** All happy paths + critical error paths
|
||||
- **E2E Tests:** Complete feature workflows
|
||||
- **Performance:** All queries <10ms (database), <100ms (MCP)
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
### Dependencies Required
|
||||
All dependencies already present in `package.json`:
|
||||
- vitest (test runner)
|
||||
- better-sqlite3 (database)
|
||||
- @vitest/coverage-v8 (coverage)
|
||||
|
||||
### Test Utilities Used
|
||||
- TestDatabase helper (from existing test utils)
|
||||
- createTestDatabaseAdapter (from existing test utils)
|
||||
- Standard vitest matchers
|
||||
|
||||
### No New Dependencies Required ✅
|
||||
|
||||
---
|
||||
|
||||
## Regression Prevention
|
||||
|
||||
### Critical Paths Protected
|
||||
|
||||
1. **Backward Compatibility**
|
||||
- Tools work without includeExamples parameter
|
||||
- Existing workflows unchanged
|
||||
- Cache keys differentiated
|
||||
|
||||
2. **Performance**
|
||||
- No degradation when includeExamples=false
|
||||
- Indexed queries <10ms
|
||||
- Example fetch errors don't break responses
|
||||
|
||||
3. **Data Integrity**
|
||||
- Foreign key constraints enforced
|
||||
- JSON validation in all fields
|
||||
- Rank calculations correct
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### GitHub Actions Updates
|
||||
No changes required. Existing test commands will run new tests:
|
||||
|
||||
```yaml
|
||||
- run: npm test
|
||||
- run: npm run test:coverage
|
||||
```
|
||||
|
||||
### Coverage Thresholds
|
||||
Current thresholds maintained. Expected improvements:
|
||||
- Lines: +2%
|
||||
- Functions: +3%
|
||||
- Branches: +2%
|
||||
|
||||
---
|
||||
|
||||
## Manual Testing Checklist
|
||||
|
||||
### Pre-Deployment Verification
|
||||
|
||||
- [ ] Run `npm run rebuild` - Verify migration applies cleanly
|
||||
- [ ] Run `npm run fetch:templates --extract-only` - Verify extraction works
|
||||
- [ ] Check database: `SELECT COUNT(*) FROM template_node_configs` - Should be ~197
|
||||
- [ ] Test MCP tool: `search_nodes({query: "webhook", includeExamples: true})`
|
||||
- [ ] Test MCP tool: `get_node_essentials({nodeType: "nodes-base.webhook", includeExamples: true})`
|
||||
- [ ] Verify backward compatibility: Tools work without includeExamples parameter
|
||||
- [ ] Performance test: Query 100 nodes with examples < 200ms
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues are detected:
|
||||
|
||||
1. **Database Rollback:**
|
||||
```sql
|
||||
DROP TABLE IF EXISTS template_node_configs;
|
||||
DROP VIEW IF EXISTS ranked_node_configs;
|
||||
```
|
||||
|
||||
2. **Code Rollback:**
|
||||
- Revert server.ts changes
|
||||
- Revert tools.ts changes
|
||||
- Restore get_node_for_task tool (if critical)
|
||||
|
||||
3. **Test Rollback:**
|
||||
- Revert parameter-validation.test.ts
|
||||
- Revert tools.test.ts
|
||||
- Revert tool-invocation.test.ts
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Test Metrics
|
||||
- ✅ 85+ new tests added
|
||||
- ✅ 0 tests failing after updates
|
||||
- ✅ Coverage increase 2%+
|
||||
- ✅ All performance tests pass
|
||||
|
||||
### Feature Metrics
|
||||
- ✅ 197 template configs extracted
|
||||
- ✅ Top 2/3 examples returned correctly
|
||||
- ✅ Query performance <10ms
|
||||
- ✅ No backward compatibility breaks
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
This test plan provides **comprehensive coverage** for the P0-R3 feature with:
|
||||
- **85+ new tests** across unit, integration, and E2E levels
|
||||
- **Complete coverage** of extraction, storage, and retrieval
|
||||
- **Backward compatibility** protection
|
||||
- **Performance validation** (<10ms queries)
|
||||
- **Clear migration path** for existing tests
|
||||
|
||||
**All test files are ready for execution.** Update the 4 existing test files as outlined, then run the full test suite.
|
||||
|
||||
**Estimated Total Implementation Time:** 2-3 hours for updating existing tests + validation
|
||||
357
README.md
357
README.md
@@ -20,6 +20,8 @@ n8n-MCP serves as a bridge between n8n's workflow automation platform and AI mod
|
||||
- ⚡ **Node operations** - 63.6% coverage of available actions
|
||||
- 📄 **Documentation** - 90% coverage from official n8n docs (including AI nodes)
|
||||
- 🤖 **AI tools** - 263 AI-capable nodes detected with full documentation
|
||||
- 💡 **Real-world examples** - 2,646 pre-extracted configurations from popular templates
|
||||
- 🎯 **Template library** - 2,500+ workflow templates with smart filtering
|
||||
|
||||
|
||||
## ⚠️ Important Safety Warning
|
||||
@@ -400,176 +402,256 @@ For the best results when using n8n-MCP with Claude Projects, use these enhanced
|
||||
```markdown
|
||||
You are an expert in n8n automation software using n8n-MCP tools. Your role is to design, build, and validate n8n workflows with maximum accuracy and efficiency.
|
||||
|
||||
## Core Workflow Process
|
||||
## Core Principles
|
||||
|
||||
1. **ALWAYS start new conversation with**: `tools_documentation()` to understand best practices and available tools.
|
||||
### 1. Silent Execution
|
||||
CRITICAL: Execute tools without commentary. Only respond AFTER all tools complete.
|
||||
|
||||
2. **Template Discovery Phase**
|
||||
- `search_templates_by_metadata({complexity: "simple"})` - Find skill-appropriate templates
|
||||
- `get_templates_for_task('webhook_processing')` - Get curated templates by task
|
||||
- `search_templates('slack notification')` - Text search for specific needs. Start by quickly searching with "id" and "name" to find the template you are looking for, only then dive deeper into the template details adding "description" to your search query.
|
||||
- `list_node_templates(['n8n-nodes-base.slack'])` - Find templates using specific nodes
|
||||
|
||||
**Template filtering strategies**:
|
||||
- **For beginners**: `complexity: "simple"` and `maxSetupMinutes: 30`
|
||||
- **By role**: `targetAudience: "marketers"` or `"developers"` or `"analysts"`
|
||||
- **By time**: `maxSetupMinutes: 15` for quick wins
|
||||
- **By service**: `requiredService: "openai"` to find compatible templates
|
||||
❌ BAD: "Let me search for Slack nodes... Great! Now let me get details..."
|
||||
✅ GOOD: [Execute search_nodes and get_node_essentials in parallel, then respond]
|
||||
|
||||
3. **Discovery Phase** - Find the right nodes (if no suitable template):
|
||||
- Think deeply about user request and the logic you are going to build to fulfill it. Ask follow-up questions to clarify the user's intent, if something is unclear. Then, proceed with the rest of your instructions.
|
||||
- `search_nodes({query: 'keyword'})` - Search by functionality
|
||||
### 2. Parallel Execution
|
||||
When operations are independent, execute them in parallel for maximum performance.
|
||||
|
||||
✅ GOOD: Call search_nodes, list_nodes, and search_templates simultaneously
|
||||
❌ BAD: Sequential tool calls (await each one before the next)
|
||||
|
||||
### 3. Templates First
|
||||
ALWAYS check templates before building from scratch (2,500+ available).
|
||||
|
||||
### 4. Multi-Level Validation
|
||||
Use validate_node_minimal → validate_node_operation → validate_workflow pattern.
|
||||
|
||||
### 5. Never Trust Defaults
|
||||
⚠️ CRITICAL: Default parameter values are the #1 source of runtime failures.
|
||||
ALWAYS explicitly configure ALL parameters that control node behavior.
|
||||
|
||||
## Workflow Process
|
||||
|
||||
1. **Start**: Call `tools_documentation()` for best practices
|
||||
|
||||
2. **Template Discovery Phase** (FIRST - parallel when searching multiple)
|
||||
- `search_templates_by_metadata({complexity: "simple"})` - Smart filtering
|
||||
- `get_templates_for_task('webhook_processing')` - Curated by task
|
||||
- `search_templates('slack notification')` - Text search
|
||||
- `list_node_templates(['n8n-nodes-base.slack'])` - By node type
|
||||
|
||||
**Filtering strategies**:
|
||||
- Beginners: `complexity: "simple"` + `maxSetupMinutes: 30`
|
||||
- By role: `targetAudience: "marketers"` | `"developers"` | `"analysts"`
|
||||
- By time: `maxSetupMinutes: 15` for quick wins
|
||||
- By service: `requiredService: "openai"` for compatibility
|
||||
|
||||
3. **Node Discovery** (if no suitable template - parallel execution)
|
||||
- Think deeply about requirements. Ask clarifying questions if unclear.
|
||||
- `search_nodes({query: 'keyword', includeExamples: true})` - Parallel for multiple nodes
|
||||
- `list_nodes({category: 'trigger'})` - Browse by category
|
||||
- `list_ai_tools()` - See AI-capable nodes (remember: ANY node can be an AI tool!)
|
||||
- `list_ai_tools()` - AI-capable nodes
|
||||
|
||||
4. **Configuration Phase** - Get node details efficiently:
|
||||
- `get_node_essentials(nodeType)` - Start here! Only 10-20 essential properties
|
||||
4. **Configuration Phase** (parallel for multiple nodes)
|
||||
- `get_node_essentials(nodeType, {includeExamples: true})` - 10-20 key properties
|
||||
- `search_node_properties(nodeType, 'auth')` - Find specific properties
|
||||
- `get_node_for_task('send_email')` - Get pre-configured templates
|
||||
- `get_node_documentation(nodeType)` - Human-readable docs when needed
|
||||
- It is good common practice to show a visual representation of the workflow architecture to the user and asking for opinion, before moving forward.
|
||||
- `get_node_documentation(nodeType)` - Human-readable docs
|
||||
- Show workflow architecture to user for approval before proceeding
|
||||
|
||||
5. **Pre-Validation Phase** - Validate BEFORE building:
|
||||
5. **Validation Phase** (parallel for multiple nodes)
|
||||
- `validate_node_minimal(nodeType, config)` - Quick required fields check
|
||||
- `validate_node_operation(nodeType, config, profile)` - Full operation-aware validation
|
||||
- Fix any validation errors before proceeding
|
||||
- `validate_node_operation(nodeType, config, 'runtime')` - Full validation with fixes
|
||||
- Fix ALL errors before proceeding
|
||||
|
||||
6. **Building Phase** - Create or customize the workflow:
|
||||
6. **Building Phase**
|
||||
- If using template: `get_template(templateId, {mode: "full"})`
|
||||
- **MANDATORY ATTRIBUTION**: When using a template, ALWAYS inform the user:
|
||||
- "This workflow is based on a template by **[author.name]** (@[author.username])"
|
||||
- "View the original template at: [url]"
|
||||
- Example: "This workflow is based on a template by **David Ashby** (@cfomodz). View the original at: https://n8n.io/workflows/2414"
|
||||
- Customize template or build from validated configurations
|
||||
- **MANDATORY ATTRIBUTION**: "Based on template by **[author.name]** (@[username]). View at: [url]"
|
||||
- Build from validated configurations
|
||||
- ⚠️ EXPLICITLY set ALL parameters - never rely on defaults
|
||||
- Connect nodes with proper structure
|
||||
- Add error handling where appropriate
|
||||
- Use expressions like $json, $node["NodeName"].json
|
||||
- Build the workflow in an artifact for easy editing downstream (unless the user asked to create in n8n instance)
|
||||
- Add error handling
|
||||
- Use n8n expressions: $json, $node["NodeName"].json
|
||||
- Build in artifact (unless deploying to n8n instance)
|
||||
|
||||
7. **Workflow Validation Phase** - Validate complete workflow:
|
||||
- `validate_workflow(workflow)` - Complete validation including connections
|
||||
- `validate_workflow_connections(workflow)` - Check structure and AI tool connections
|
||||
- `validate_workflow_expressions(workflow)` - Validate all n8n expressions
|
||||
- Fix any issues found before deployment
|
||||
7. **Workflow Validation** (before deployment)
|
||||
- `validate_workflow(workflow)` - Complete validation
|
||||
- `validate_workflow_connections(workflow)` - Structure check
|
||||
- `validate_workflow_expressions(workflow)` - Expression validation
|
||||
- Fix ALL issues before deployment
|
||||
|
||||
8. **Deployment Phase** (if n8n API configured):
|
||||
- `n8n_create_workflow(workflow)` - Deploy validated workflow
|
||||
- `n8n_validate_workflow({id: 'workflow-id'})` - Post-deployment validation
|
||||
- `n8n_update_partial_workflow()` - Make incremental updates using diffs
|
||||
- `n8n_trigger_webhook_workflow()` - Test webhook workflows
|
||||
8. **Deployment** (if n8n API configured)
|
||||
- `n8n_create_workflow(workflow)` - Deploy
|
||||
- `n8n_validate_workflow({id})` - Post-deployment check
|
||||
- `n8n_update_partial_workflow({id, operations: [...]})` - Batch updates
|
||||
- `n8n_trigger_webhook_workflow()` - Test webhooks
|
||||
|
||||
## Key Insights
|
||||
## Critical Warnings
|
||||
|
||||
- **TEMPLATES FIRST** - Always check for existing templates before building from scratch (2,500+ available!)
|
||||
- **ATTRIBUTION REQUIRED** - Always credit template authors with name, username, and link to n8n.io
|
||||
- **SMART FILTERING** - Use metadata filters to find templates matching user skill level and time constraints
|
||||
- **USE CODE NODE ONLY WHEN IT IS NECESSARY** - always prefer to use standard nodes over code node. Use code node only when you are sure you need it.
|
||||
- **VALIDATE EARLY AND OFTEN** - Catch errors before they reach deployment
|
||||
- **USE DIFF UPDATES** - Use n8n_update_partial_workflow for 80-90% token savings
|
||||
- **ANY node can be an AI tool** - not just those with usableAsTool=true
|
||||
- **Pre-validate configurations** - Use validate_node_minimal before building
|
||||
- **Post-validate workflows** - Always validate complete workflows before deployment
|
||||
- **Incremental updates** - Use diff operations for existing workflows
|
||||
- **Test thoroughly** - Validate both locally and after deployment to n8n
|
||||
### ⚠️ Never Trust Defaults
|
||||
Default values cause runtime failures. Example:
|
||||
```javascript
|
||||
// ❌ FAILS at runtime
|
||||
{resource: "message", operation: "post", text: "Hello"}
|
||||
|
||||
// ✅ WORKS - all parameters explicit
|
||||
{resource: "message", operation: "post", select: "channel", channelId: "C123", text: "Hello"}
|
||||
```
|
||||
|
||||
### ⚠️ Example Availability
|
||||
`includeExamples: true` returns real configurations from workflow templates.
|
||||
- Coverage varies by node popularity
|
||||
- When no examples available, use `get_node_essentials` + `validate_node_minimal`
|
||||
|
||||
## Validation Strategy
|
||||
|
||||
### Before Building:
|
||||
1. validate_node_minimal() - Check required fields
|
||||
2. validate_node_operation() - Full configuration validation
|
||||
3. Fix all errors before proceeding
|
||||
### Level 1 - Quick Check (before building)
|
||||
`validate_node_minimal(nodeType, config)` - Required fields only (<100ms)
|
||||
|
||||
### After Building:
|
||||
1. validate_workflow() - Complete workflow validation
|
||||
2. validate_workflow_connections() - Structure validation
|
||||
3. validate_workflow_expressions() - Expression syntax check
|
||||
### Level 2 - Comprehensive (before building)
|
||||
`validate_node_operation(nodeType, config, 'runtime')` - Full validation with fixes
|
||||
|
||||
### After Deployment:
|
||||
1. n8n_validate_workflow({id}) - Validate deployed workflow
|
||||
2. n8n_autofix_workflow({id}) - Auto-fix common errors (expressions, typeVersion, webhooks)
|
||||
3. n8n_list_executions() - Monitor execution status
|
||||
4. n8n_update_partial_workflow() - Fix issues using diffs
|
||||
### Level 3 - Complete (after building)
|
||||
`validate_workflow(workflow)` - Connections, expressions, AI tools
|
||||
|
||||
## Response Structure
|
||||
### Level 4 - Post-Deployment
|
||||
1. `n8n_validate_workflow({id})` - Validate deployed workflow
|
||||
2. `n8n_autofix_workflow({id})` - Auto-fix common errors
|
||||
3. `n8n_list_executions()` - Monitor execution status
|
||||
|
||||
1. **Discovery**: Show available nodes and options
|
||||
2. **Pre-Validation**: Validate node configurations first
|
||||
3. **Configuration**: Show only validated, working configs
|
||||
4. **Building**: Construct workflow with validated components
|
||||
5. **Workflow Validation**: Full workflow validation results
|
||||
6. **Deployment**: Deploy only after all validations pass
|
||||
7. **Post-Validation**: Verify deployment succeeded
|
||||
## Response Format
|
||||
|
||||
### Initial Creation
|
||||
```
|
||||
[Silent tool execution in parallel]
|
||||
|
||||
Created workflow:
|
||||
- Webhook trigger → Slack notification
|
||||
- Configured: POST /webhook → #general channel
|
||||
|
||||
Validation: ✅ All checks passed
|
||||
```
|
||||
|
||||
### Modifications
|
||||
```
|
||||
[Silent tool execution]
|
||||
|
||||
Updated workflow:
|
||||
- Added error handling to HTTP node
|
||||
- Fixed required Slack parameters
|
||||
|
||||
Changes validated successfully.
|
||||
```
|
||||
|
||||
## Batch Operations
|
||||
|
||||
Use `n8n_update_partial_workflow` with multiple operations in a single call:
|
||||
|
||||
✅ GOOD - Batch multiple operations:
|
||||
```javascript
|
||||
n8n_update_partial_workflow({
|
||||
id: "wf-123",
|
||||
operations: [
|
||||
{type: "updateNode", nodeId: "slack-1", changes: {...}},
|
||||
{type: "updateNode", nodeId: "http-1", changes: {...}},
|
||||
{type: "cleanStaleConnections"}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
❌ BAD - Separate calls:
|
||||
```javascript
|
||||
n8n_update_partial_workflow({id: "wf-123", operations: [{...}]})
|
||||
n8n_update_partial_workflow({id: "wf-123", operations: [{...}]})
|
||||
```
|
||||
|
||||
## Example Workflow
|
||||
|
||||
### Smart Template-First Approach
|
||||
### Template-First Approach
|
||||
|
||||
#### 1. Find existing templates
|
||||
// Find simple Slack templates for marketers
|
||||
const templates = search_templates_by_metadata({
|
||||
```javascript
|
||||
// STEP 1: Template Discovery (parallel execution)
|
||||
[Silent execution]
|
||||
search_templates_by_metadata({
|
||||
requiredService: 'slack',
|
||||
complexity: 'simple',
|
||||
targetAudience: 'marketers',
|
||||
maxSetupMinutes: 30
|
||||
targetAudience: 'marketers'
|
||||
})
|
||||
|
||||
// Or search by text
|
||||
search_templates('slack notification')
|
||||
|
||||
// Or get curated templates
|
||||
get_templates_for_task('slack_integration')
|
||||
|
||||
#### 2. Use and customize template
|
||||
const workflow = get_template(templates.items[0].id, {mode: 'full'})
|
||||
// STEP 2: Use template
|
||||
get_template(templateId, {mode: 'full'})
|
||||
validate_workflow(workflow)
|
||||
|
||||
### Building from Scratch (if no suitable template)
|
||||
// Response after all tools complete:
|
||||
"Found template by **David Ashby** (@cfomodz).
|
||||
View at: https://n8n.io/workflows/2414
|
||||
|
||||
#### 1. Discovery & Configuration
|
||||
search_nodes({query: 'slack'})
|
||||
get_node_essentials('n8n-nodes-base.slack')
|
||||
Validation: ✅ All checks passed"
|
||||
```
|
||||
|
||||
#### 2. Pre-Validation
|
||||
validate_node_minimal('n8n-nodes-base.slack', {resource:'message', operation:'send'})
|
||||
### Building from Scratch (if no template)
|
||||
|
||||
```javascript
|
||||
// STEP 1: Discovery (parallel execution)
|
||||
[Silent execution]
|
||||
search_nodes({query: 'slack', includeExamples: true})
|
||||
list_nodes({category: 'communication'})
|
||||
|
||||
// STEP 2: Configuration (parallel execution)
|
||||
[Silent execution]
|
||||
get_node_essentials('n8n-nodes-base.slack', {includeExamples: true})
|
||||
get_node_essentials('n8n-nodes-base.webhook', {includeExamples: true})
|
||||
|
||||
// STEP 3: Validation (parallel execution)
|
||||
[Silent execution]
|
||||
validate_node_minimal('n8n-nodes-base.slack', config)
|
||||
validate_node_operation('n8n-nodes-base.slack', fullConfig, 'runtime')
|
||||
|
||||
#### 3. Build Workflow
|
||||
// Create workflow JSON with validated configs
|
||||
// STEP 4: Build
|
||||
// Construct workflow with validated configs
|
||||
// ⚠️ Set ALL parameters explicitly
|
||||
|
||||
#### 4. Workflow Validation
|
||||
// STEP 5: Validate
|
||||
[Silent execution]
|
||||
validate_workflow(workflowJson)
|
||||
validate_workflow_connections(workflowJson)
|
||||
validate_workflow_expressions(workflowJson)
|
||||
|
||||
#### 5. Deploy (if configured)
|
||||
n8n_create_workflow(validatedWorkflow)
|
||||
n8n_validate_workflow({id: createdWorkflowId})
|
||||
// Response after all tools complete:
|
||||
"Created workflow: Webhook → Slack
|
||||
Validation: ✅ Passed"
|
||||
```
|
||||
|
||||
#### 6. Update Using Diffs
|
||||
### Batch Updates
|
||||
|
||||
```javascript
|
||||
// ONE call with multiple operations
|
||||
n8n_update_partial_workflow({
|
||||
workflowId: id,
|
||||
id: "wf-123",
|
||||
operations: [
|
||||
{type: 'updateNode', nodeId: 'slack1', changes: {position: [100, 200]}}
|
||||
{type: "updateNode", nodeId: "slack-1", changes: {position: [100, 200]}},
|
||||
{type: "updateNode", nodeId: "http-1", changes: {position: [300, 200]}},
|
||||
{type: "cleanStaleConnections"}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## Important Rules
|
||||
|
||||
- ALWAYS check for existing templates before building from scratch
|
||||
- LEVERAGE metadata filters to find skill-appropriate templates
|
||||
- **ALWAYS ATTRIBUTE TEMPLATES**: When using any template, you MUST share the author's name, username, and link to the original template on n8n.io
|
||||
- VALIDATE templates before deployment (they may need updates)
|
||||
- USE diff operations for updates (80-90% token savings)
|
||||
- STATE validation results clearly
|
||||
- FIX all errors before proceeding
|
||||
### Core Behavior
|
||||
1. **Silent execution** - No commentary between tools
|
||||
2. **Parallel by default** - Execute independent operations simultaneously
|
||||
3. **Templates first** - Always check before building (2,500+ available)
|
||||
4. **Multi-level validation** - Quick check → Full validation → Workflow validation
|
||||
5. **Never trust defaults** - Explicitly configure ALL parameters
|
||||
|
||||
## Template Discovery Tips
|
||||
### Attribution & Credits
|
||||
- **MANDATORY TEMPLATE ATTRIBUTION**: Share author name, username, and n8n.io link
|
||||
- **Template validation** - Always validate before deployment (may need updates)
|
||||
|
||||
- **97.5% of templates have metadata** - Use smart filtering!
|
||||
- **Filter combinations work best** - Combine complexity + setup time + service
|
||||
- **Templates save 70-90% development time** - Always check first
|
||||
- **Metadata is AI-generated** - Occasionally imprecise but highly useful
|
||||
- **Use `includeMetadata: false` for fast browsing** - Add metadata only when needed
|
||||
### Performance
|
||||
- **Batch operations** - Use diff operations with multiple changes in one call
|
||||
- **Parallel execution** - Search, validate, and configure simultaneously
|
||||
- **Template metadata** - Use smart filtering for faster discovery
|
||||
|
||||
### Code Node Usage
|
||||
- **Avoid when possible** - Prefer standard nodes
|
||||
- **Only when necessary** - Use code node as last resort
|
||||
- **AI tool capability** - ANY node can be an AI tool (not just marked ones)
|
||||
```
|
||||
|
||||
Save these instructions in your Claude Project for optimal n8n workflow assistance with intelligent template discovery.
|
||||
@@ -588,11 +670,11 @@ This tool was created to benefit everyone in the n8n community without friction.
|
||||
## Features
|
||||
|
||||
- **🔍 Smart Node Search**: Find nodes by name, category, or functionality
|
||||
- **📖 Essential Properties**: Get only the 10-20 properties that matter (NEW in v2.4.0)
|
||||
- **🎯 Task Templates**: Pre-configured settings for common automation tasks
|
||||
- **📖 Essential Properties**: Get only the 10-20 properties that matter
|
||||
- **💡 Real-World Examples**: 2,646 pre-extracted configurations from popular templates
|
||||
- **✅ Config Validation**: Validate node configurations before deployment
|
||||
- **🔗 Dependency Analysis**: Understand property relationships and conditions
|
||||
- **💡 Working Examples**: Real-world examples for immediate use
|
||||
- **🎯 Template Discovery**: 2,500+ workflow templates with smart filtering
|
||||
- **⚡ Fast Response**: Average query time ~12ms with optimized SQLite
|
||||
- **🌐 Universal Compatibility**: Works with any Node.js version
|
||||
|
||||
@@ -618,8 +700,8 @@ Once connected, Claude can use these powerful tools:
|
||||
- **`tools_documentation`** - Get documentation for any MCP tool (START HERE!)
|
||||
- **`list_nodes`** - List all n8n nodes with filtering options
|
||||
- **`get_node_info`** - Get comprehensive information about a specific node
|
||||
- **`get_node_essentials`** - Get only essential properties with examples (10-20 properties instead of 200+)
|
||||
- **`search_nodes`** - Full-text search across all node documentation
|
||||
- **`get_node_essentials`** - Get only essential properties (10-20 instead of 200+). Use `includeExamples: true` to get top 3 real-world configurations from popular templates
|
||||
- **`search_nodes`** - Full-text search across all node documentation. Use `includeExamples: true` to get top 2 real-world configurations per node from templates
|
||||
- **`search_node_properties`** - Find specific properties within nodes
|
||||
- **`list_ai_tools`** - List all AI-capable nodes (ANY node can be used as AI tool!)
|
||||
- **`get_node_as_tool_info`** - Get guidance on using any node as an AI tool
|
||||
@@ -633,8 +715,6 @@ Once connected, Claude can use these powerful tools:
|
||||
- **`get_templates_for_task`** - Curated templates for common automation tasks
|
||||
|
||||
### Advanced Tools
|
||||
- **`get_node_for_task`** - Pre-configured node settings for common tasks
|
||||
- **`list_tasks`** - Discover available task templates
|
||||
- **`validate_node_operation`** - Validate node configurations (operation-aware, profiles support)
|
||||
- **`validate_node_minimal`** - Quick validation for just required fields
|
||||
- **`validate_workflow`** - Complete workflow validation including AI tool connections
|
||||
@@ -674,14 +754,17 @@ These powerful tools allow you to manage n8n workflows directly from Claude. The
|
||||
### Example Usage
|
||||
|
||||
```typescript
|
||||
// Get essentials for quick configuration
|
||||
get_node_essentials("nodes-base.httpRequest")
|
||||
// Get essentials with real-world examples from templates
|
||||
get_node_essentials({
|
||||
nodeType: "nodes-base.httpRequest",
|
||||
includeExamples: true // Returns top 3 configs from popular templates
|
||||
})
|
||||
|
||||
// Find nodes for a specific task
|
||||
search_nodes({ query: "send email gmail" })
|
||||
|
||||
// Get pre-configured settings
|
||||
get_node_for_task("send_email")
|
||||
// Search nodes with configuration examples
|
||||
search_nodes({
|
||||
query: "send email gmail",
|
||||
includeExamples: true // Returns top 2 configs per node
|
||||
})
|
||||
|
||||
// Validate before deployment
|
||||
validate_node_operation({
|
||||
@@ -772,12 +855,14 @@ npm run dev:http # HTTP dev mode
|
||||
|
||||
## 📊 Metrics & Coverage
|
||||
|
||||
Current database coverage (n8n v1.106.3):
|
||||
Current database coverage (n8n v1.113.3):
|
||||
|
||||
- ✅ **535/535** nodes loaded (100%)
|
||||
- ✅ **536/536** nodes loaded (100%)
|
||||
- ✅ **528** nodes with properties (98.7%)
|
||||
- ✅ **470** nodes with documentation (88%)
|
||||
- ✅ **267** AI-capable tools detected
|
||||
- ✅ **2,646** pre-extracted template configurations
|
||||
- ✅ **2,500+** workflow templates available
|
||||
- ✅ **AI Agent & LangChain nodes** fully documented
|
||||
- ⚡ **Average response time**: ~12ms
|
||||
- 💾 **Database size**: ~15MB (optimized)
|
||||
|
||||
BIN
data/nodes.db
BIN
data/nodes.db
Binary file not shown.
0
data/templates.db
Normal file
0
data/templates.db
Normal file
1213
docs/local/DEEP_DIVE_ANALYSIS_2025-10-02.md
Normal file
1213
docs/local/DEEP_DIVE_ANALYSIS_2025-10-02.md
Normal file
File diff suppressed because it is too large
Load Diff
225
docs/local/DEEP_DIVE_ANALYSIS_README.md
Normal file
225
docs/local/DEEP_DIVE_ANALYSIS_README.md
Normal file
@@ -0,0 +1,225 @@
|
||||
# N8N-MCP Deep Dive Analysis - October 2, 2025
|
||||
|
||||
## Overview
|
||||
|
||||
This directory contains a comprehensive deep-dive analysis of n8n-mcp usage data from September 26 - October 2, 2025.
|
||||
|
||||
**Data Volume Analyzed:**
|
||||
- 212,375 telemetry events
|
||||
- 5,751 workflow creations
|
||||
- 2,119 unique users
|
||||
- 6 days of usage data
|
||||
|
||||
## Report Structure
|
||||
|
||||
|
||||
###: `DEEP_DIVE_ANALYSIS_2025-10-02.md` (Main Report)
|
||||
|
||||
**Sections Covered:**
|
||||
1. **Executive Summary** - Key findings and recommendations
|
||||
2. **Tool Performance Analysis** - Success rates, performance metrics, critical findings
|
||||
3. **Validation Catastrophe** - The node type prefix disaster analysis
|
||||
4. **Usage Patterns & User Segmentation** - User distribution, daily trends
|
||||
5. **Tool Sequence Analysis** - How AI agents use tools together
|
||||
6. **Workflow Creation Patterns** - Complexity distribution, popular nodes
|
||||
7. **Platform & Version Distribution** - OS, architecture, version adoption
|
||||
8. **Error Patterns & Root Causes** - TypeErrors, validation errors, discovery failures
|
||||
9. **P0-P1 Refactoring Recommendations** - Detailed implementation guides
|
||||
|
||||
**Sections Covered:**
|
||||
- Remaining P1 and P2 recommendations
|
||||
- Architectural refactoring suggestions
|
||||
- Telemetry enhancements
|
||||
- CHANGELOG integration
|
||||
- Final recommendations summary
|
||||
|
||||
## Key Findings Summary
|
||||
|
||||
### Critical Issues (P0 - Fix Immediately)
|
||||
|
||||
1. **Node Type Prefix Validation Catastrophe**
|
||||
- 5,000+ validation errors from single root cause
|
||||
- `nodes-base.X` vs `n8n-nodes-base.X` confusion
|
||||
- **Solution**: Auto-normalize prefixes (2-4 hours effort)
|
||||
|
||||
2. **TypeError in Node Information Tools**
|
||||
- 10-18% failure rate in get_node_essentials/info
|
||||
- 1,000+ failures affecting hundreds of users
|
||||
- **Solution**: Complete null-safety audit (1 day effort)
|
||||
|
||||
3. **Task Discovery Failures**
|
||||
- `get_node_for_task` failing 28% of the time
|
||||
- Worst-performing tool in entire system
|
||||
- **Solution**: Expand task library + fuzzy matching (3 days effort)
|
||||
|
||||
### Performance Metrics
|
||||
|
||||
**Excellent Reliability (96-100% success):**
|
||||
- n8n_update_partial_workflow: 98.7%
|
||||
- search_nodes: 99.8%
|
||||
- n8n_create_workflow: 96.1%
|
||||
- All workflow management tools: 100%
|
||||
|
||||
**User Distribution:**
|
||||
- Power Users (12): 2,112 events/user, 33 workflows
|
||||
- Heavy Users (47): 673 events/user, 18 workflows
|
||||
- Regular Users (516): 199 events/user, 7 workflows (CORE AUDIENCE)
|
||||
- Active Users (919): 52 events/user, 2 workflows
|
||||
- Casual Users (625): 8 events/user, 1 workflow
|
||||
|
||||
### Usage Insights
|
||||
|
||||
**Most Used Tools:**
|
||||
1. n8n_update_partial_workflow: 10,177 calls (iterative refinement)
|
||||
2. search_nodes: 8,839 calls (node discovery)
|
||||
3. n8n_create_workflow: 6,046 calls (workflow creation)
|
||||
|
||||
**Most Common Tool Sequences:**
|
||||
1. update → update → update (549x) - Iterative refinement pattern
|
||||
2. create → update (297x) - Create then refine
|
||||
3. update → get_workflow (265x) - Update then verify
|
||||
|
||||
**Most Popular Nodes:**
|
||||
1. code (53% of workflows) - AI agents love programmatic control
|
||||
2. httpRequest (47%) - Integration-heavy usage
|
||||
3. webhook (32%) - Event-driven automation
|
||||
|
||||
## SQL Analytical Views Created
|
||||
|
||||
15 comprehensive views were created in Supabase for ongoing analysis:
|
||||
|
||||
1. `vw_tool_performance` - Performance metrics per tool
|
||||
2. `vw_error_analysis` - Error patterns and frequencies
|
||||
3. `vw_validation_analysis` - Validation failure details
|
||||
4. `vw_tool_sequences` - Tool-to-tool transition patterns
|
||||
5. `vw_workflow_creation_patterns` - Workflow characteristics
|
||||
6. `vw_node_usage_analysis` - Node popularity and complexity
|
||||
7. `vw_node_cooccurrence` - Which nodes are used together
|
||||
8. `vw_user_activity` - Per-user activity metrics
|
||||
9. `vw_session_analysis` - Platform/version distribution
|
||||
10. `vw_workflow_validation_failures` - Workflow validation issues
|
||||
11. `vw_temporal_patterns` - Time-based usage patterns
|
||||
12. `vw_tool_funnel` - User progression through tools
|
||||
13. `vw_search_analysis` - Search behavior
|
||||
14. `vw_tool_success_summary` - Success/failure rates
|
||||
15. `vw_user_journeys` - Complete user session reconstruction
|
||||
|
||||
## Priority Recommendations
|
||||
|
||||
### Immediate Actions (This Week)
|
||||
|
||||
✅ **P0-R1**: Auto-normalize node type prefixes → Eliminate 4,800 errors
|
||||
✅ **P0-R2**: Complete null-safety audit → Fix 10-18% TypeError failures
|
||||
✅ **P0-R3**: Expand get_node_for_task library → 72% → 95% success rate
|
||||
|
||||
**Expected Impact**: Reduce error rate from 5-10% to <2% overall
|
||||
|
||||
### Next Release (2-3 Weeks)
|
||||
|
||||
✅ **P1-R4**: Batch workflow operations → Save 30-50% tokens
|
||||
✅ **P1-R5**: Proactive node suggestions → Reduce search iterations
|
||||
✅ **P1-R6**: Auto-fix suggestions in errors → Self-service recovery
|
||||
|
||||
**Expected Impact**: 40% faster workflow creation, better UX
|
||||
|
||||
### Future Roadmap (1-3 Months)
|
||||
|
||||
✅ **A1**: Service layer consolidation → Cleaner architecture
|
||||
✅ **A2**: Repository caching → 50% faster node operations
|
||||
✅ **R10**: Workflow template library from usage → 80% coverage
|
||||
✅ **T1-T3**: Enhanced telemetry → Better observability
|
||||
|
||||
**Expected Impact**: Scalable foundation for 10x growth
|
||||
|
||||
## Methodology
|
||||
|
||||
### Data Sources
|
||||
|
||||
1. **Supabase Telemetry Database**
|
||||
- `telemetry_events` table: 212,375 rows
|
||||
- `telemetry_workflows` table: 5,751 rows
|
||||
|
||||
2. **Analytical Views**
|
||||
- Created 15 SQL views for multi-dimensional analysis
|
||||
- Enabled complex queries and pattern recognition
|
||||
|
||||
3. **CHANGELOG Review**
|
||||
- Analyzed recent changes (v2.14.0 - v2.14.6)
|
||||
- Correlated fixes with error patterns
|
||||
|
||||
### Analysis Approach
|
||||
|
||||
1. **Quantitative Analysis**
|
||||
- Success/failure rates per tool
|
||||
- Performance metrics (avg, median, p95, p99)
|
||||
- User segmentation and cohort analysis
|
||||
- Temporal trends and growth patterns
|
||||
|
||||
2. **Pattern Recognition**
|
||||
- Tool sequence analysis (Markov chains)
|
||||
- Node co-occurrence patterns
|
||||
- Workflow complexity distribution
|
||||
- Error clustering and root cause analysis
|
||||
|
||||
3. **Qualitative Insights**
|
||||
- CHANGELOG integration
|
||||
- Error message analysis
|
||||
- User journey reconstruction
|
||||
- Best practice identification
|
||||
|
||||
## How to Use This Analysis
|
||||
|
||||
### For Development Priorities
|
||||
|
||||
1. Review **P0 Critical Recommendations** (Section 8)
|
||||
2. Check estimated effort and impact
|
||||
3. Prioritize based on ROI (impact/effort ratio)
|
||||
4. Follow implementation guides with code examples
|
||||
|
||||
### For Architecture Decisions
|
||||
|
||||
1. Review **Architectural Recommendations** (Section 9)
|
||||
2. Consider service layer consolidation
|
||||
3. Evaluate repository caching opportunities
|
||||
4. Plan for 10x scale
|
||||
|
||||
### For Product Strategy
|
||||
|
||||
1. Review **Usage Patterns** (Section 3 & 5)
|
||||
2. Understand user segments (power vs casual)
|
||||
3. Identify high-value features (most-used tools)
|
||||
4. Focus on reliability over features (96% success rate target)
|
||||
|
||||
### For Telemetry Enhancement
|
||||
|
||||
1. Review **Telemetry Enhancements** (Section 10)
|
||||
2. Add fine-grained timing metrics
|
||||
3. Track workflow creation funnels
|
||||
4. Monitor node-level analytics
|
||||
|
||||
## Contact & Feedback
|
||||
|
||||
For questions about this analysis or to request additional insights:
|
||||
- Data Analyst: Claude Code with Supabase MCP
|
||||
- Analysis Date: October 2, 2025
|
||||
- Data Period: September 26 - October 2, 2025
|
||||
|
||||
## Change Log
|
||||
|
||||
- **2025-10-02**: Initial comprehensive analysis completed
|
||||
- 15 SQL analytical views created
|
||||
- 13 sections of detailed findings
|
||||
- P0/P1/P2 recommendations with implementation guides
|
||||
- Code examples and effort estimates provided
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Review findings with development team
|
||||
2. ✅ Prioritize P0 recommendations for immediate implementation
|
||||
3. ✅ Plan P1 features for next release cycle
|
||||
4. ✅ Set up monitoring for key metrics
|
||||
5. ✅ Schedule follow-up analysis (weekly recommended)
|
||||
|
||||
---
|
||||
|
||||
*This analysis represents a snapshot of n8n-mcp usage during early adoption phase. Patterns may evolve as the user base grows and matures.*
|
||||
1328
docs/local/Deep_dive_p1_p2.md
Normal file
1328
docs/local/Deep_dive_p1_p2.md
Normal file
File diff suppressed because it is too large
Load Diff
3396
docs/local/N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md
Normal file
3396
docs/local/N8N_AI_WORKFLOW_BUILDER_ANALYSIS.md
Normal file
File diff suppressed because it is too large
Load Diff
1489
docs/local/P0_IMPLEMENTATION_PLAN.md
Normal file
1489
docs/local/P0_IMPLEMENTATION_PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
369
docs/local/TEMPLATE_MINING_ANALYSIS.md
Normal file
369
docs/local/TEMPLATE_MINING_ANALYSIS.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# Template Mining Analysis - Alternative to P0-R3
|
||||
|
||||
**Date**: 2025-10-02
|
||||
**Context**: Analyzing whether to fix `get_node_for_task` (28% failure rate) or replace it with template-based configuration extraction
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**RECOMMENDATION**: Replace `get_node_for_task` with template-based configuration extraction. The template database contains 2,646 real-world workflows with rich node configurations that far exceed the 31 hardcoded task templates.
|
||||
|
||||
## Key Findings
|
||||
|
||||
### 1. Template Database Coverage
|
||||
|
||||
- **Total Templates**: 2,646 production workflows from n8n.io
|
||||
- **Unique Node Types**: 543 (covers 103% of our 525 core nodes)
|
||||
- **Metadata Coverage**: 100% (AI-generated structured metadata)
|
||||
|
||||
### 2. Node Type Coverage in Templates
|
||||
|
||||
Top node types by template usage:
|
||||
```
|
||||
3,820 templates: n8n-nodes-base.httpRequest (144% of total templates!)
|
||||
3,678 templates: n8n-nodes-base.set
|
||||
2,445 templates: n8n-nodes-base.code
|
||||
1,700 templates: n8n-nodes-base.googleSheets
|
||||
1,471 templates: @n8n/n8n-nodes-langchain.agent
|
||||
1,269 templates: @n8n/n8n-nodes-langchain.lmChatOpenAi
|
||||
792 templates: n8n-nodes-base.telegram
|
||||
702 templates: n8n-nodes-base.httpRequestTool
|
||||
596 templates: n8n-nodes-base.gmail
|
||||
466 templates: n8n-nodes-base.webhook
|
||||
```
|
||||
|
||||
**Comparison**:
|
||||
- Hardcoded task templates: 31 tasks covering 5.9% of nodes
|
||||
- Real templates: 2,646 templates with 2-3k examples for common nodes
|
||||
|
||||
### 3. Database Structure
|
||||
|
||||
```sql
|
||||
CREATE TABLE templates (
|
||||
id INTEGER PRIMARY KEY,
|
||||
workflow_id INTEGER UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- Node information
|
||||
nodes_used TEXT, -- JSON array: ["n8n-nodes-base.httpRequest", ...]
|
||||
workflow_json_compressed TEXT, -- Base64 encoded gzip of full workflow
|
||||
-- Metadata (100% coverage)
|
||||
metadata_json TEXT, -- AI-generated structured metadata
|
||||
-- Stats
|
||||
views INTEGER DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
-- ...
|
||||
);
|
||||
```
|
||||
|
||||
### 4. Real Configuration Examples
|
||||
|
||||
#### HTTP Request Node Configurations
|
||||
|
||||
**Simple URL fetch**:
|
||||
```json
|
||||
{
|
||||
"url": "https://api.example.com/data",
|
||||
"options": {}
|
||||
}
|
||||
```
|
||||
|
||||
**With authentication**:
|
||||
```json
|
||||
{
|
||||
"url": "=https://api.wavespeed.ai/api/v3/predictions/{{ $json.data.id }}/result",
|
||||
"options": {},
|
||||
"authentication": "genericCredentialType",
|
||||
"genericAuthType": "httpHeaderAuth"
|
||||
}
|
||||
```
|
||||
|
||||
**Complex expressions**:
|
||||
```json
|
||||
{
|
||||
"url": "=https://image.pollinations.ai/prompt/{{$('Social Media Content Factory').item.json.output.description.replaceAll(' ','-').replaceAll(',','').replaceAll('.','') }}",
|
||||
"options": {}
|
||||
}
|
||||
```
|
||||
|
||||
#### Webhook Node Configurations
|
||||
|
||||
**Basic webhook**:
|
||||
```json
|
||||
{
|
||||
"path": "ytube",
|
||||
"options": {},
|
||||
"httpMethod": "POST",
|
||||
"responseMode": "responseNode"
|
||||
}
|
||||
```
|
||||
|
||||
**With binary data**:
|
||||
```json
|
||||
{
|
||||
"path": "your-endpoint",
|
||||
"options": {
|
||||
"binaryPropertyName": "data"
|
||||
},
|
||||
"httpMethod": "POST"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. AI-Generated Metadata
|
||||
|
||||
Each template has structured metadata including:
|
||||
|
||||
```json
|
||||
{
|
||||
"categories": ["automation", "integration", "data processing"],
|
||||
"complexity": "medium",
|
||||
"use_cases": [
|
||||
"Extract transaction data from Gmail",
|
||||
"Automate bookkeeping",
|
||||
"Expense tracking"
|
||||
],
|
||||
"estimated_setup_minutes": 30,
|
||||
"required_services": ["Gmail", "Google Sheets", "Google Gemini"],
|
||||
"key_features": [
|
||||
"Fetch emails by label",
|
||||
"Extract transaction data",
|
||||
"Use LLM for structured output"
|
||||
],
|
||||
"target_audience": ["Accountants", "Small business owners"]
|
||||
}
|
||||
```
|
||||
|
||||
## Comparison: Task Templates vs Real Templates
|
||||
|
||||
### Current Approach (get_node_for_task)
|
||||
|
||||
**Pros**:
|
||||
- Curated configurations with best practices
|
||||
- Predictable, stable responses
|
||||
- Fast lookup (no decompression needed)
|
||||
|
||||
**Cons**:
|
||||
- Only 31 tasks (5.9% node coverage)
|
||||
- 28% failure rate (users can't find what they need)
|
||||
- Requires manual maintenance
|
||||
- Static configurations without real-world context
|
||||
- Usage ratio 22.5:1 (search_nodes is preferred)
|
||||
|
||||
### Template-Based Approach
|
||||
|
||||
**Pros**:
|
||||
- 2,646 real workflows with 2-3k examples for common nodes
|
||||
- 100% metadata coverage for semantic matching
|
||||
- Real-world patterns and best practices
|
||||
- Covers 543 node types (103% coverage)
|
||||
- Self-updating (templates fetched from n8n.io)
|
||||
- Rich context (use cases, complexity, setup time)
|
||||
|
||||
**Cons**:
|
||||
- Requires decompression for full workflow access
|
||||
- May contain template-specific context (but can be filtered)
|
||||
- Need ranking/filtering logic for best matches
|
||||
|
||||
## Proposed Implementation Strategy
|
||||
|
||||
### Phase 1: Extract Node Configurations from Templates
|
||||
|
||||
Create a new service: `TemplateConfigExtractor`
|
||||
|
||||
```typescript
|
||||
interface ExtractedNodeConfig {
|
||||
nodeType: string;
|
||||
configuration: Record<string, any>;
|
||||
source: {
|
||||
templateId: number;
|
||||
templateName: string;
|
||||
templateViews: number;
|
||||
useCases: string[];
|
||||
complexity: 'simple' | 'medium' | 'complex';
|
||||
};
|
||||
patterns: {
|
||||
hasAuthentication: boolean;
|
||||
hasExpressions: boolean;
|
||||
hasOptionalFields: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
class TemplateConfigExtractor {
|
||||
async extractConfigsForNode(
|
||||
nodeType: string,
|
||||
options?: {
|
||||
complexity?: 'simple' | 'medium' | 'complex';
|
||||
requiresAuth?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
): Promise<ExtractedNodeConfig[]> {
|
||||
// 1. Query templates containing nodeType
|
||||
// 2. Decompress workflow_json_compressed
|
||||
// 3. Extract node configurations
|
||||
// 4. Rank by popularity + complexity match
|
||||
// 5. Return top N configurations
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: Integrate with Existing Tools
|
||||
|
||||
**Option A**: Enhance `get_node_essentials`
|
||||
- Add `includeExamples: boolean` parameter
|
||||
- Return 2-3 real configurations from templates
|
||||
- Preserve existing compact format
|
||||
|
||||
**Option B**: Enhance `get_node_info`
|
||||
- Add `examples` section with template-sourced configs
|
||||
- Include source attribution (template name, views)
|
||||
|
||||
**Option C**: New tool `get_node_examples`
|
||||
- Dedicated tool for retrieving configuration examples
|
||||
- Query by node type, complexity, use case
|
||||
- Returns ranked list of real configurations
|
||||
|
||||
### Phase 3: Deprecate get_node_for_task
|
||||
|
||||
- Mark as deprecated in tool documentation
|
||||
- Redirect to enhanced tools
|
||||
- Remove after 2-3 version cycles
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Decompression Cost
|
||||
|
||||
- Average compressed size: 6-12 KB
|
||||
- Decompression time: ~5-10ms per template
|
||||
- Caching strategy needed for frequently accessed templates
|
||||
|
||||
### Query Strategy
|
||||
|
||||
```sql
|
||||
-- Fast: Get templates for a node type (no decompression)
|
||||
SELECT id, name, views, metadata_json
|
||||
FROM templates
|
||||
WHERE nodes_used LIKE '%n8n-nodes-base.httpRequest%'
|
||||
ORDER BY views DESC
|
||||
LIMIT 10;
|
||||
|
||||
-- Then decompress only top matches
|
||||
```
|
||||
|
||||
### Caching
|
||||
|
||||
- Cache decompressed workflows for popular templates (top 100)
|
||||
- TTL: 1 hour
|
||||
- Estimated memory: 100 * 50KB = 5MB
|
||||
|
||||
## Impact on P0-R3
|
||||
|
||||
**Original P0-R3 Plan**: Expand task library from 31 to 100+ tasks using fuzzy matching
|
||||
|
||||
**New Approach**: Mine 2,646 templates for real configurations
|
||||
|
||||
**Impact Assessment**:
|
||||
|
||||
| Metric | Original Plan | Template Mining |
|
||||
|--------|--------------|-----------------|
|
||||
| Configuration examples | 100 (estimated) | 2,646+ actual |
|
||||
| Node coverage | ~20% | 103% |
|
||||
| Maintenance | High (manual) | Low (auto-fetch) |
|
||||
| Accuracy | Curated | Production-tested |
|
||||
| Context richness | Limited | Rich metadata |
|
||||
| Development time | 2-3 weeks | 1 week |
|
||||
|
||||
**Recommendation**: PIVOT to template mining approach for P0-R3
|
||||
|
||||
## Implementation Estimate
|
||||
|
||||
### Week 1: Core Infrastructure
|
||||
- Day 1-2: Create `TemplateConfigExtractor` service
|
||||
- Day 3: Implement caching layer
|
||||
- Day 4-5: Testing and optimization
|
||||
|
||||
### Week 2: Integration
|
||||
- Day 1-2: Enhance `get_node_essentials` with examples
|
||||
- Day 3: Update tool documentation
|
||||
- Day 4-5: Integration testing
|
||||
|
||||
**Total**: 2 weeks vs 3 weeks for original plan
|
||||
|
||||
## Validation Tests
|
||||
|
||||
```typescript
|
||||
// Test: Extract HTTP Request configs
|
||||
const configs = await extractor.extractConfigsForNode(
|
||||
'n8n-nodes-base.httpRequest',
|
||||
{ complexity: 'simple', limit: 5 }
|
||||
);
|
||||
|
||||
// Expected: 5 configs from top templates
|
||||
// - Simple URL fetch
|
||||
// - With authentication
|
||||
// - With custom headers
|
||||
// - With expressions
|
||||
// - With error handling
|
||||
|
||||
// Test: Extract webhook configs
|
||||
const webhookConfigs = await extractor.extractConfigsForNode(
|
||||
'n8n-nodes-base.webhook',
|
||||
{ limit: 3 }
|
||||
);
|
||||
|
||||
// Expected: 3 configs showing different patterns
|
||||
// - Basic POST webhook
|
||||
// - With response node
|
||||
// - With binary data handling
|
||||
```
|
||||
|
||||
## Risks and Mitigation
|
||||
|
||||
### Risk 1: Template Quality Varies
|
||||
- **Mitigation**: Filter by views (popularity) and metadata complexity rating
|
||||
- Only use templates with >1000 views for examples
|
||||
|
||||
### Risk 2: Decompression Performance
|
||||
- **Mitigation**: Cache decompressed popular templates
|
||||
- Implement lazy loading (decompress on demand)
|
||||
|
||||
### Risk 3: Template-Specific Context
|
||||
- **Mitigation**: Extract only node configuration, strip workflow-specific context
|
||||
- Provide source attribution for context
|
||||
|
||||
### Risk 4: Breaking Changes in Template Structure
|
||||
- **Mitigation**: Robust error handling in decompression
|
||||
- Fallback to cached configs if template fetch fails
|
||||
|
||||
## Success Metrics
|
||||
|
||||
**Before** (get_node_for_task):
|
||||
- 392 calls, 72% success rate
|
||||
- 28% failure rate
|
||||
- 31 task templates
|
||||
- 5.9% node coverage
|
||||
|
||||
**Target** (template-based):
|
||||
- 90%+ success rate for configuration discovery
|
||||
- 100%+ node coverage
|
||||
- 2,646+ real-world examples
|
||||
- Self-updating from n8n.io
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Complete template database analysis
|
||||
2. ⏳ Create `TemplateConfigExtractor` service
|
||||
3. ⏳ Implement caching layer
|
||||
4. ⏳ Enhance `get_node_essentials` with examples
|
||||
5. ⏳ Update P0 implementation plan
|
||||
6. ⏳ Begin implementation
|
||||
|
||||
## Conclusion
|
||||
|
||||
The template database provides a vastly superior alternative to hardcoded task templates:
|
||||
|
||||
- **2,646 templates** vs 31 tasks (85x more examples)
|
||||
- **103% node coverage** vs 5.9% coverage (17x improvement)
|
||||
- **Real-world configurations** vs synthetic examples
|
||||
- **Self-updating** vs manual maintenance
|
||||
- **Rich metadata** for semantic matching
|
||||
|
||||
**Recommendation**: Pivot P0-R3 from "expand task library" to "mine template configurations"
|
||||
924
docs/local/integration-testing-plan.md
Normal file
924
docs/local/integration-testing-plan.md
Normal file
@@ -0,0 +1,924 @@
|
||||
# Comprehensive Integration Testing Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Transform the test suite to test all 17 n8n API handlers against a **real n8n instance** instead of mocks. This plan ensures 100% coverage of every tool, operation, and parameter combination to prevent bugs like the P0 workflow creation issue from slipping through.
|
||||
|
||||
## Critical Requirements
|
||||
|
||||
1. **Credentials**:
|
||||
- Local development: Read from `.env` file
|
||||
- CI/GitHub Actions: Use GitHub secrets (`N8N_URL`, `N8N_API_KEY`)
|
||||
|
||||
2. **Pre-activated Webhook Workflows**:
|
||||
- n8n API doesn't support workflow activation via API
|
||||
- Need pre-created, activated workflows for webhook testing
|
||||
- Store workflow IDs in `.env`:
|
||||
- `N8N_TEST_WEBHOOK_GET_ID` - Webhook with GET method
|
||||
- `N8N_TEST_WEBHOOK_POST_ID` - Webhook with POST method
|
||||
- `N8N_TEST_WEBHOOK_PUT_ID` - Webhook with PUT method
|
||||
- `N8N_TEST_WEBHOOK_DELETE_ID` - Webhook with DELETE method
|
||||
|
||||
3. **100% Coverage Goal**: Test EVERY tool, EVERY operation, EVERY parameter combination
|
||||
|
||||
---
|
||||
|
||||
## Complete Test Coverage Matrix
|
||||
|
||||
### Total Test Scenarios: ~150+
|
||||
|
||||
#### Workflow Management (10 handlers)
|
||||
|
||||
**1. `handleCreateWorkflow`** - 10+ scenarios
|
||||
- Create workflow with base nodes (webhook, httpRequest, set)
|
||||
- Create workflow with langchain nodes (agent, aiChain)
|
||||
- Invalid node types (error handling)
|
||||
- Complex multi-node workflows
|
||||
- Complex connection patterns
|
||||
- **P0 Bug Verification**: SHORT vs FULL node type handling
|
||||
- Missing required parameters
|
||||
- Duplicate node names
|
||||
- Invalid connection references
|
||||
- Settings variations
|
||||
|
||||
**2. `handleGetWorkflow`** - 3 scenarios
|
||||
- Successful retrieval
|
||||
- Not found (invalid ID)
|
||||
- Malformed ID
|
||||
|
||||
**3. `handleGetWorkflowDetails`** - 4 scenarios
|
||||
- Basic workflow
|
||||
- Workflow with metadata
|
||||
- Workflow with version history
|
||||
- Workflow with execution stats
|
||||
|
||||
**4. `handleGetWorkflowStructure`** - 2 scenarios
|
||||
- Simple workflow
|
||||
- Complex workflow (verify no parameter data)
|
||||
|
||||
**5. `handleGetWorkflowMinimal`** - 2 scenarios
|
||||
- Active workflow
|
||||
- Inactive workflow
|
||||
|
||||
**6. `handleUpdateWorkflow`** - 8+ scenarios
|
||||
- Full workflow replacement
|
||||
- Update nodes
|
||||
- Update connections
|
||||
- Update settings
|
||||
- Update tags
|
||||
- Validation errors
|
||||
- Concurrent update conflicts
|
||||
- Large workflow updates
|
||||
|
||||
**7. `handleUpdatePartialWorkflow`** - 30+ scenarios (15 operations × 2 paths)
|
||||
|
||||
**Node Operations (12 scenarios):**
|
||||
- `addNode`: Success, duplicate name, invalid type, missing position
|
||||
- `removeNode`: By ID, by name, not found, with connection cleanup
|
||||
- `updateNode`: By ID, by name, invalid updates, nested parameter updates
|
||||
- `moveNode`: Valid position, boundary positions
|
||||
- `enableNode`: Success, already enabled
|
||||
- `disableNode`: Success, already disabled
|
||||
|
||||
**Connection Operations (10 scenarios):**
|
||||
- `addConnection`: Default ports, custom ports, invalid nodes
|
||||
- `removeConnection`: Success, not found, with ignoreErrors
|
||||
- `updateConnection`: Change ports, change indices
|
||||
- `cleanStaleConnections`: Dry run, actual cleanup
|
||||
- `replaceConnections`: Full replacement, validation
|
||||
|
||||
**Metadata Operations (8 scenarios):**
|
||||
- `updateSettings`: Timezone, execution order, error workflow
|
||||
- `updateName`: Valid, duplicate, empty
|
||||
- `addTag`: New tag, existing tag
|
||||
- `removeTag`: Existing, non-existing
|
||||
|
||||
**8. `handleDeleteWorkflow`** - 3 scenarios
|
||||
- Successful deletion
|
||||
- Not found
|
||||
- Verify cleanup (workflow actually deleted)
|
||||
|
||||
**9. `handleListWorkflows`** - 12+ scenarios
|
||||
- No filters (all workflows)
|
||||
- Filter by active status (true/false)
|
||||
- Filter by tags (single, multiple)
|
||||
- Filter by projectId (enterprise feature)
|
||||
- Pagination: first page, next page, last page
|
||||
- Pagination: cursor handling
|
||||
- Exclude pinned data
|
||||
- Limit variations (1, 50, 100)
|
||||
- Empty results
|
||||
- Sort order verification
|
||||
|
||||
**10. `handleValidateWorkflow`** - 16 scenarios (4 profiles × 4 validation types)
|
||||
|
||||
**Validation Profiles:**
|
||||
- `strict`: All validations enabled, strictest rules
|
||||
- `runtime`: Production-ready validation
|
||||
- `ai-friendly`: Relaxed rules for AI-generated workflows
|
||||
- `minimal`: Basic structure validation only
|
||||
|
||||
**Validation Types (for each profile):**
|
||||
- All validations enabled (default)
|
||||
- Nodes only (`validateNodes: true`, others false)
|
||||
- Connections only (`validateConnections: true`, others false)
|
||||
- Expressions only (`validateExpressions: true`, others false)
|
||||
|
||||
**11. `handleAutofixWorkflow`** - 20+ scenarios
|
||||
|
||||
**Fix Types (5):**
|
||||
- `expression-format`: Fix `{{}}` syntax issues
|
||||
- `typeversion-correction`: Fix outdated typeVersion
|
||||
- `error-output-config`: Fix error output configuration
|
||||
- `node-type-correction`: Fix incorrect node types
|
||||
- `webhook-missing-path`: Add missing webhook paths
|
||||
|
||||
**Confidence Levels (3):**
|
||||
- `high`: Only apply high-confidence fixes
|
||||
- `medium`: Apply high + medium confidence fixes
|
||||
- `low`: Apply all fixes
|
||||
|
||||
**Test Matrix:**
|
||||
- Each fix type with preview mode (`applyFixes: false`)
|
||||
- Each fix type with apply mode (`applyFixes: true`)
|
||||
- Confidence threshold filtering
|
||||
- `maxFixes` parameter limiting
|
||||
- Multiple fix types in single workflow
|
||||
- No fixes available scenario
|
||||
|
||||
---
|
||||
|
||||
#### Execution Management (4 handlers)
|
||||
|
||||
**12. `handleTriggerWebhookWorkflow`** - 16+ scenarios
|
||||
|
||||
**HTTP Methods (4):**
|
||||
- GET: Query parameters, no data
|
||||
- POST: JSON body, form data, headers
|
||||
- PUT: Update data, custom headers
|
||||
- DELETE: Query parameters, headers
|
||||
|
||||
**Scenarios per method:**
|
||||
- Basic trigger (no data)
|
||||
- With request data
|
||||
- With custom headers
|
||||
- Wait for response (true/false)
|
||||
- Workflow not found
|
||||
- Invalid webhook URL
|
||||
|
||||
**13. `handleGetExecution`** - 20+ scenarios
|
||||
|
||||
**Execution Modes (4):**
|
||||
- `preview`: Structure & counts only (no data)
|
||||
- `summary`: 2 samples per node (default)
|
||||
- `filtered`: Custom limits and node filters
|
||||
- `full`: Complete execution data
|
||||
|
||||
**Scenarios per mode:**
|
||||
- Successful execution
|
||||
- Failed execution
|
||||
- Running execution
|
||||
- With input data (`includeInputData: true`)
|
||||
- Node filters (`nodeNames: ['Node1', 'Node2']`)
|
||||
- Item limits (`itemsLimit: 0, 2, 5, -1`)
|
||||
- Not found
|
||||
|
||||
**14. `handleListExecutions`** - 10+ scenarios
|
||||
- No filters (all executions)
|
||||
- Filter by workflowId
|
||||
- Filter by status (success, error, waiting)
|
||||
- Filter by projectId
|
||||
- Pagination: first page, next page, last page
|
||||
- Include execution data (`includeData: true/false`)
|
||||
- Limit variations (1, 50, 100)
|
||||
- Empty results
|
||||
|
||||
**15. `handleDeleteExecution`** - 3 scenarios
|
||||
- Successful deletion
|
||||
- Not found
|
||||
- Verify cleanup
|
||||
|
||||
---
|
||||
|
||||
#### System/Utility (3 handlers)
|
||||
|
||||
**16. `handleHealthCheck`** - 2 scenarios
|
||||
- API available
|
||||
- Feature availability check
|
||||
|
||||
**17. `handleListAvailableTools`** - 1 scenario
|
||||
- List all tools
|
||||
|
||||
**18. `handleDiagnostic`** - 3 scenarios
|
||||
- Basic diagnostic
|
||||
- Verbose mode (`verbose: true`)
|
||||
- Configuration display
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Foundation (Branch: `feat/integration-tests-foundation`)
|
||||
|
||||
#### 1.1 Environment Configuration
|
||||
|
||||
**Update `.env.example`:**
|
||||
```bash
|
||||
# ========================================
|
||||
# INTEGRATION TESTING CONFIGURATION
|
||||
# ========================================
|
||||
|
||||
# n8n API Configuration for Integration Tests
|
||||
N8N_API_URL=http://localhost:5678
|
||||
N8N_API_KEY=your-api-key-here
|
||||
|
||||
# Pre-activated Webhook Workflows for Testing
|
||||
# Create these workflows manually in n8n and activate them
|
||||
# Each workflow should have a single Webhook node with the specified HTTP method
|
||||
N8N_TEST_WEBHOOK_GET_ID= # Webhook with GET method
|
||||
N8N_TEST_WEBHOOK_POST_ID= # Webhook with POST method
|
||||
N8N_TEST_WEBHOOK_PUT_ID= # Webhook with PUT method
|
||||
N8N_TEST_WEBHOOK_DELETE_ID= # Webhook with DELETE method
|
||||
|
||||
# Test Configuration
|
||||
N8N_TEST_CLEANUP_ENABLED=true # Enable automatic cleanup
|
||||
N8N_TEST_TAG=mcp-integration-test # Tag for test workflows
|
||||
N8N_TEST_NAME_PREFIX=[MCP-TEST] # Name prefix for test workflows
|
||||
```
|
||||
|
||||
**GitHub Secrets (for CI):**
|
||||
- `N8N_URL`: n8n instance URL
|
||||
- `N8N_API_KEY`: n8n API key
|
||||
- `N8N_TEST_WEBHOOK_GET_ID`: Pre-activated GET webhook workflow ID
|
||||
- `N8N_TEST_WEBHOOK_POST_ID`: Pre-activated POST webhook workflow ID
|
||||
- `N8N_TEST_WEBHOOK_PUT_ID`: Pre-activated PUT webhook workflow ID
|
||||
- `N8N_TEST_WEBHOOK_DELETE_ID`: Pre-activated DELETE webhook workflow ID
|
||||
|
||||
#### 1.2 Directory Structure
|
||||
|
||||
```
|
||||
tests/integration/n8n-api/
|
||||
├── workflows/
|
||||
│ ├── create-workflow.test.ts (10+ scenarios)
|
||||
│ ├── get-workflow.test.ts (3 scenarios)
|
||||
│ ├── get-workflow-details.test.ts (4 scenarios)
|
||||
│ ├── get-workflow-structure.test.ts (2 scenarios)
|
||||
│ ├── get-workflow-minimal.test.ts (2 scenarios)
|
||||
│ ├── update-workflow.test.ts (8+ scenarios)
|
||||
│ ├── update-partial-workflow.test.ts (30+ scenarios - 15 operations)
|
||||
│ ├── delete-workflow.test.ts (3 scenarios)
|
||||
│ ├── list-workflows.test.ts (12+ scenarios)
|
||||
│ ├── validate-workflow.test.ts (16 scenarios - 4 profiles × 4 types)
|
||||
│ └── autofix-workflow.test.ts (20+ scenarios - 5 types × modes)
|
||||
├── executions/
|
||||
│ ├── trigger-webhook.test.ts (16+ scenarios - 4 methods)
|
||||
│ ├── get-execution.test.ts (20+ scenarios - 4 modes)
|
||||
│ ├── list-executions.test.ts (10+ scenarios)
|
||||
│ └── delete-execution.test.ts (3 scenarios)
|
||||
├── system/
|
||||
│ ├── health-check.test.ts (2 scenarios)
|
||||
│ ├── list-tools.test.ts (1 scenario)
|
||||
│ └── diagnostic.test.ts (3 scenarios)
|
||||
└── utils/
|
||||
├── credentials.ts # Environment-aware credential loader
|
||||
├── n8n-client.ts # Pre-configured API client
|
||||
├── cleanup-helpers.ts # Multi-level cleanup
|
||||
├── test-context.ts # Resource tracking
|
||||
├── fixtures.ts # Reusable workflow templates
|
||||
├── factories.ts # Test data generators
|
||||
└── webhook-workflows.ts # Webhook workflow configurations
|
||||
```
|
||||
|
||||
#### 1.3 Core Utilities
|
||||
|
||||
**credentials.ts** - Environment-aware credential loader:
|
||||
```typescript
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export interface N8nTestCredentials {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
webhookWorkflows: {
|
||||
get: string;
|
||||
post: string;
|
||||
put: string;
|
||||
delete: string;
|
||||
};
|
||||
cleanup: {
|
||||
enabled: boolean;
|
||||
tag: string;
|
||||
namePrefix: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function getN8nCredentials(): N8nTestCredentials {
|
||||
if (process.env.CI) {
|
||||
// CI: Use GitHub secrets
|
||||
return {
|
||||
url: process.env.N8N_URL!,
|
||||
apiKey: process.env.N8N_API_KEY!,
|
||||
webhookWorkflows: {
|
||||
get: process.env.N8N_TEST_WEBHOOK_GET_ID!,
|
||||
post: process.env.N8N_TEST_WEBHOOK_POST_ID!,
|
||||
put: process.env.N8N_TEST_WEBHOOK_PUT_ID!,
|
||||
delete: process.env.N8N_TEST_WEBHOOK_DELETE_ID!
|
||||
},
|
||||
cleanup: {
|
||||
enabled: true,
|
||||
tag: 'mcp-integration-test',
|
||||
namePrefix: '[MCP-TEST]'
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Local: Use .env file
|
||||
return {
|
||||
url: process.env.N8N_API_URL!,
|
||||
apiKey: process.env.N8N_API_KEY!,
|
||||
webhookWorkflows: {
|
||||
get: process.env.N8N_TEST_WEBHOOK_GET_ID || '',
|
||||
post: process.env.N8N_TEST_WEBHOOK_POST_ID || '',
|
||||
put: process.env.N8N_TEST_WEBHOOK_PUT_ID || '',
|
||||
delete: process.env.N8N_TEST_WEBHOOK_DELETE_ID || ''
|
||||
},
|
||||
cleanup: {
|
||||
enabled: process.env.N8N_TEST_CLEANUP_ENABLED !== 'false',
|
||||
tag: process.env.N8N_TEST_TAG || 'mcp-integration-test',
|
||||
namePrefix: process.env.N8N_TEST_NAME_PREFIX || '[MCP-TEST]'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCredentials(creds: N8nTestCredentials): void {
|
||||
if (!creds.url) throw new Error('N8N_API_URL is required');
|
||||
if (!creds.apiKey) throw new Error('N8N_API_KEY is required');
|
||||
}
|
||||
|
||||
export function validateWebhookWorkflows(creds: N8nTestCredentials): void {
|
||||
const missing: string[] = [];
|
||||
if (!creds.webhookWorkflows.get) missing.push('GET');
|
||||
if (!creds.webhookWorkflows.post) missing.push('POST');
|
||||
if (!creds.webhookWorkflows.put) missing.push('PUT');
|
||||
if (!creds.webhookWorkflows.delete) missing.push('DELETE');
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Missing webhook workflow IDs for HTTP methods: ${missing.join(', ')}\n` +
|
||||
`Please create and activate webhook workflows, then set:\n` +
|
||||
missing.map(m => ` N8N_TEST_WEBHOOK_${m}_ID`).join('\n')
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**n8n-client.ts** - Pre-configured API client wrapper:
|
||||
```typescript
|
||||
import { N8nApiClient } from '../../../src/services/n8n-api-client';
|
||||
import { getN8nCredentials } from './credentials';
|
||||
|
||||
let client: N8nApiClient | null = null;
|
||||
|
||||
export function getTestN8nClient(): N8nApiClient {
|
||||
if (!client) {
|
||||
const creds = getN8nCredentials();
|
||||
client = new N8nApiClient(creds.url, creds.apiKey);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
export function resetTestN8nClient(): void {
|
||||
client = null;
|
||||
}
|
||||
```
|
||||
|
||||
**test-context.ts** - Resource tracking for cleanup:
|
||||
```typescript
|
||||
import { getN8nCredentials } from './credentials';
|
||||
|
||||
export interface TestContext {
|
||||
workflowIds: string[];
|
||||
executionIds: string[];
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createTestContext(): TestContext {
|
||||
const context: TestContext = {
|
||||
workflowIds: [],
|
||||
executionIds: [],
|
||||
cleanup: async () => {
|
||||
const creds = getN8nCredentials();
|
||||
if (!creds.cleanup.enabled) return;
|
||||
|
||||
const client = getTestN8nClient();
|
||||
|
||||
// Delete executions first
|
||||
for (const id of context.executionIds) {
|
||||
try {
|
||||
await client.deleteExecution(id);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to delete execution ${id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Then delete workflows
|
||||
for (const id of context.workflowIds) {
|
||||
try {
|
||||
await client.deleteWorkflow(id);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to delete workflow ${id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
context.workflowIds = [];
|
||||
context.executionIds = [];
|
||||
}
|
||||
};
|
||||
|
||||
return context;
|
||||
}
|
||||
```
|
||||
|
||||
**cleanup-helpers.ts** - Multi-level cleanup strategies:
|
||||
```typescript
|
||||
import { N8nApiClient } from '../../../src/services/n8n-api-client';
|
||||
import { getN8nCredentials, getTestN8nClient } from './credentials';
|
||||
|
||||
/**
|
||||
* Clean up orphaned test workflows
|
||||
* Run this periodically in CI to clean up failed test runs
|
||||
*/
|
||||
export async function cleanupOrphanedWorkflows(): Promise<void> {
|
||||
const creds = getN8nCredentials();
|
||||
const client = getTestN8nClient();
|
||||
|
||||
let allWorkflows: any[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
// Fetch all workflows with pagination
|
||||
do {
|
||||
const response = await client.listWorkflows({ cursor, limit: 100 });
|
||||
allWorkflows.push(...response.data);
|
||||
cursor = response.nextCursor;
|
||||
} while (cursor);
|
||||
|
||||
// Find test workflows
|
||||
const testWorkflows = allWorkflows.filter(w =>
|
||||
w.tags?.includes(creds.cleanup.tag) ||
|
||||
w.name?.startsWith(creds.cleanup.namePrefix)
|
||||
);
|
||||
|
||||
console.log(`Found ${testWorkflows.length} orphaned test workflows`);
|
||||
|
||||
// Delete them
|
||||
for (const workflow of testWorkflows) {
|
||||
try {
|
||||
await client.deleteWorkflow(workflow.id);
|
||||
console.log(`Deleted orphaned workflow: ${workflow.name} (${workflow.id})`);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to delete workflow ${workflow.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old executions (older than 24 hours)
|
||||
*/
|
||||
export async function cleanupOldExecutions(): Promise<void> {
|
||||
const client = getTestN8nClient();
|
||||
|
||||
let allExecutions: any[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
// Fetch all executions
|
||||
do {
|
||||
const response = await client.listExecutions({ cursor, limit: 100 });
|
||||
allExecutions.push(...response.data);
|
||||
cursor = response.nextCursor;
|
||||
} while (cursor);
|
||||
|
||||
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
|
||||
const oldExecutions = allExecutions.filter(e =>
|
||||
new Date(e.startedAt).getTime() < oneDayAgo
|
||||
);
|
||||
|
||||
console.log(`Found ${oldExecutions.length} old executions`);
|
||||
|
||||
for (const execution of oldExecutions) {
|
||||
try {
|
||||
await client.deleteExecution(execution.id);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to delete execution ${execution.id}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**fixtures.ts** - Reusable workflow templates:
|
||||
```typescript
|
||||
import { Workflow } from '../../../src/types/n8n-api';
|
||||
|
||||
export const SIMPLE_WEBHOOK_WORKFLOW: Partial<Workflow> = {
|
||||
name: '[MCP-TEST] Simple Webhook',
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'test-webhook'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
export const SIMPLE_HTTP_WORKFLOW: Partial<Workflow> = {
|
||||
name: '[MCP-TEST] Simple HTTP Request',
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'trigger'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'http-1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4.2,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
url: 'https://httpbin.org/get',
|
||||
method: 'GET'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Add more fixtures for complex workflows
|
||||
```
|
||||
|
||||
**webhook-workflows.ts** - Webhook workflow setup guide:
|
||||
```typescript
|
||||
/**
|
||||
* Guide for setting up webhook workflows manually in n8n
|
||||
*
|
||||
* These workflows must be created manually and activated because
|
||||
* n8n API doesn't support workflow activation.
|
||||
*
|
||||
* For each HTTP method, create a workflow with:
|
||||
* 1. Single Webhook node
|
||||
* 2. Configured for the specific HTTP method
|
||||
* 3. Unique webhook path
|
||||
* 4. Activated in n8n UI
|
||||
* 5. Workflow ID added to .env
|
||||
*/
|
||||
|
||||
export const WEBHOOK_WORKFLOW_CONFIGS = {
|
||||
GET: {
|
||||
name: '[MCP-TEST] Webhook GET',
|
||||
description: 'Pre-activated webhook for GET method testing',
|
||||
nodes: [
|
||||
{
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'mcp-test-get',
|
||||
responseMode: 'lastNode'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
POST: {
|
||||
name: '[MCP-TEST] Webhook POST',
|
||||
description: 'Pre-activated webhook for POST method testing',
|
||||
nodes: [
|
||||
{
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
parameters: {
|
||||
httpMethod: 'POST',
|
||||
path: 'mcp-test-post',
|
||||
responseMode: 'lastNode'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
PUT: {
|
||||
name: '[MCP-TEST] Webhook PUT',
|
||||
description: 'Pre-activated webhook for PUT method testing',
|
||||
nodes: [
|
||||
{
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
parameters: {
|
||||
httpMethod: 'PUT',
|
||||
path: 'mcp-test-put',
|
||||
responseMode: 'lastNode'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
DELETE: {
|
||||
name: '[MCP-TEST] Webhook DELETE',
|
||||
description: 'Pre-activated webhook for DELETE method testing',
|
||||
nodes: [
|
||||
{
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
parameters: {
|
||||
httpMethod: 'DELETE',
|
||||
path: 'mcp-test-delete',
|
||||
responseMode: 'lastNode'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export function printSetupInstructions(): void {
|
||||
console.log(`
|
||||
╔════════════════════════════════════════════════════════════════╗
|
||||
║ WEBHOOK WORKFLOW SETUP REQUIRED ║
|
||||
╠════════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ Integration tests require 4 pre-activated webhook workflows: ║
|
||||
║ ║
|
||||
║ 1. Create workflows manually in n8n UI ║
|
||||
║ 2. Use the configurations shown below ║
|
||||
║ 3. ACTIVATE each workflow in n8n UI ║
|
||||
║ 4. Copy workflow IDs to .env file ║
|
||||
║ ║
|
||||
╚════════════════════════════════════════════════════════════════╝
|
||||
|
||||
Required workflows:
|
||||
`);
|
||||
|
||||
Object.entries(WEBHOOK_WORKFLOW_CONFIGS).forEach(([method, config]) => {
|
||||
console.log(`
|
||||
${method} Method:
|
||||
Name: ${config.name}
|
||||
Path: ${config.nodes[0].parameters.path}
|
||||
.env variable: N8N_TEST_WEBHOOK_${method}_ID
|
||||
`);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Workflow Creation Tests (P0)
|
||||
|
||||
**Branch**: `feat/integration-tests-workflow-creation`
|
||||
|
||||
**File**: `tests/integration/n8n-api/workflows/create-workflow.test.ts`
|
||||
|
||||
**10+ Test Scenarios**:
|
||||
1. Create workflow with base webhook node (verify P0 bug fix)
|
||||
2. Create workflow with base HTTP request node
|
||||
3. Create workflow with langchain agent node
|
||||
4. Create complex multi-node workflow
|
||||
5. Create workflow with complex connections
|
||||
6. Error: Invalid node type
|
||||
7. Error: Missing required parameters
|
||||
8. Error: Duplicate node names
|
||||
9. Error: Invalid connection references
|
||||
10. Create workflow with custom settings
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Workflow Retrieval Tests (P1)
|
||||
|
||||
**Branch**: `feat/integration-tests-workflow-retrieval`
|
||||
|
||||
**Files**:
|
||||
- `get-workflow.test.ts` (3 scenarios)
|
||||
- `get-workflow-details.test.ts` (4 scenarios)
|
||||
- `get-workflow-structure.test.ts` (2 scenarios)
|
||||
- `get-workflow-minimal.test.ts` (2 scenarios)
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Workflow Update Tests (P1)
|
||||
|
||||
**Branch**: `feat/integration-tests-workflow-updates`
|
||||
|
||||
**Files**:
|
||||
- `update-workflow.test.ts` (8+ scenarios)
|
||||
- `update-partial-workflow.test.ts` (30+ scenarios covering all 15 operations)
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Workflow Management Tests (P2)
|
||||
|
||||
**Branch**: `feat/integration-tests-workflow-management`
|
||||
|
||||
**Files**:
|
||||
- `delete-workflow.test.ts` (3 scenarios)
|
||||
- `list-workflows.test.ts` (12+ scenarios with all filters and pagination)
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Validation & Autofix Tests (P2)
|
||||
|
||||
**Branch**: `feat/integration-tests-validation`
|
||||
|
||||
**Files**:
|
||||
- `validate-workflow.test.ts` (16 scenarios: 4 profiles × 4 validation types)
|
||||
- `autofix-workflow.test.ts` (20+ scenarios: 5 fix types × confidence levels)
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Execution Management Tests (P2)
|
||||
|
||||
**Branch**: `feat/integration-tests-executions`
|
||||
|
||||
**Files**:
|
||||
- `trigger-webhook.test.ts` (16+ scenarios: 4 HTTP methods × variations)
|
||||
- `get-execution.test.ts` (20+ scenarios: 4 modes × filters)
|
||||
- `list-executions.test.ts` (10+ scenarios)
|
||||
- `delete-execution.test.ts` (3 scenarios)
|
||||
|
||||
**Special Considerations for Webhook Testing**:
|
||||
- Use pre-activated workflows from `.env`
|
||||
- Each HTTP method uses a different workflow ID
|
||||
- Test both successful triggers and error cases
|
||||
- Verify response data for synchronous executions
|
||||
|
||||
---
|
||||
|
||||
### Phase 8: System Tools Tests (P3)
|
||||
|
||||
**Branch**: `feat/integration-tests-system`
|
||||
|
||||
**Files**:
|
||||
- `health-check.test.ts` (2 scenarios)
|
||||
- `list-tools.test.ts` (1 scenario)
|
||||
- `diagnostic.test.ts` (3 scenarios)
|
||||
|
||||
---
|
||||
|
||||
### Phase 9: CI/CD Integration
|
||||
|
||||
**Branch**: `feat/integration-tests-ci`
|
||||
|
||||
**GitHub Actions Workflow** (`.github/workflows/integration-tests.yml`):
|
||||
|
||||
```yaml
|
||||
name: Integration Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
schedule:
|
||||
- cron: '0 2 * * *' # Daily at 2 AM
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
integration-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
|
||||
- name: Run integration tests
|
||||
env:
|
||||
N8N_URL: ${{ secrets.N8N_URL }}
|
||||
N8N_API_KEY: ${{ secrets.N8N_API_KEY }}
|
||||
N8N_TEST_WEBHOOK_GET_ID: ${{ secrets.N8N_TEST_WEBHOOK_GET_ID }}
|
||||
N8N_TEST_WEBHOOK_POST_ID: ${{ secrets.N8N_TEST_WEBHOOK_POST_ID }}
|
||||
N8N_TEST_WEBHOOK_PUT_ID: ${{ secrets.N8N_TEST_WEBHOOK_PUT_ID }}
|
||||
N8N_TEST_WEBHOOK_DELETE_ID: ${{ secrets.N8N_TEST_WEBHOOK_DELETE_ID }}
|
||||
CI: true
|
||||
run: npm run test:integration
|
||||
|
||||
- name: Cleanup orphaned workflows
|
||||
if: always()
|
||||
env:
|
||||
N8N_URL: ${{ secrets.N8N_URL }}
|
||||
N8N_API_KEY: ${{ secrets.N8N_API_KEY }}
|
||||
run: npm run test:cleanup:orphans
|
||||
```
|
||||
|
||||
**Add npm scripts to `package.json`**:
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test:integration:n8n": "vitest run tests/integration/n8n-api",
|
||||
"test:cleanup:orphans": "tsx tests/integration/n8n-api/utils/cleanup-orphans.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Isolation Strategy
|
||||
|
||||
### Workflow Naming Convention
|
||||
- Prefix: `[MCP-TEST]`
|
||||
- Include test name: `[MCP-TEST] Create Workflow - Base Nodes`
|
||||
- Include timestamp for uniqueness: `[MCP-TEST] Test Name ${Date.now()}`
|
||||
|
||||
### Workflow Tagging
|
||||
- All test workflows tagged with: `mcp-integration-test`
|
||||
- Enables bulk cleanup queries
|
||||
|
||||
### Cleanup Levels
|
||||
1. **Test-level**: After each test via `afterEach` hook
|
||||
2. **Suite-level**: After each test file via `afterAll` hook
|
||||
3. **CI-level**: After CI job completes (always run)
|
||||
4. **Orphan cleanup**: Periodic job to clean up failed test runs
|
||||
|
||||
---
|
||||
|
||||
## Pre-Test Setup Checklist
|
||||
|
||||
### Local Development
|
||||
1. ✅ Install n8n locally or use Docker
|
||||
2. ✅ Start n8n instance: `npx n8n start`
|
||||
3. ✅ Create 4 webhook workflows (GET, POST, PUT, DELETE)
|
||||
4. ✅ Activate all 4 webhook workflows in n8n UI
|
||||
5. ✅ Get workflow IDs from n8n UI
|
||||
6. ✅ Copy `.env.example` to `.env`
|
||||
7. ✅ Set `N8N_API_URL=http://localhost:5678`
|
||||
8. ✅ Generate API key in n8n Settings > API
|
||||
9. ✅ Set `N8N_API_KEY=<your-key>`
|
||||
10. ✅ Set all 4 `N8N_TEST_WEBHOOK_*_ID` variables
|
||||
|
||||
### CI/GitHub Actions
|
||||
1. ✅ Set up cloud n8n instance (or self-hosted)
|
||||
2. ✅ Create 4 webhook workflows (GET, POST, PUT, DELETE)
|
||||
3. ✅ Activate all 4 webhook workflows
|
||||
4. ✅ Add GitHub secrets: `N8N_URL`, `N8N_API_KEY`
|
||||
5. ✅ Add webhook workflow ID secrets (4 total)
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- ✅ All 17 handlers have integration tests
|
||||
- ✅ All operations/parameters covered (150+ scenarios)
|
||||
- ✅ Tests run successfully locally and in CI
|
||||
- ✅ No manual cleanup required (automatic)
|
||||
- ✅ Test coverage catches P0-level bugs
|
||||
- ✅ CI runs on every PR and daily
|
||||
- ✅ Clear error messages when tests fail
|
||||
- ✅ Documentation for webhook workflow setup
|
||||
|
||||
---
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
- **Phase 1 (Foundation)**: 2-3 days
|
||||
- **Phase 2 (Workflow Creation)**: 1 day
|
||||
- **Phase 3 (Retrieval)**: 1 day
|
||||
- **Phase 4 (Updates)**: 2-3 days (15 operations)
|
||||
- **Phase 5 (Management)**: 1 day
|
||||
- **Phase 6 (Validation)**: 2 days
|
||||
- **Phase 7 (Executions)**: 2 days
|
||||
- **Phase 8 (System)**: 1 day
|
||||
- **Phase 9 (CI/CD)**: 1 day
|
||||
|
||||
**Total**: ~14-18 days
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Each phase should be developed on a separate branch
|
||||
- Phases can be parallelized where dependencies allow
|
||||
- Run local tests frequently to catch issues early
|
||||
- Document any n8n API quirks discovered during testing
|
||||
260
docs/local/integration-tests-phase1-summary.md
Normal file
260
docs/local/integration-tests-phase1-summary.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# Integration Tests Phase 1: Foundation - COMPLETED
|
||||
|
||||
## Overview
|
||||
Phase 1 establishes the foundation for n8n API integration testing. All core utilities, fixtures, and infrastructure are now in place.
|
||||
|
||||
## Branch
|
||||
`feat/integration-tests-foundation`
|
||||
|
||||
## Completed Tasks
|
||||
|
||||
### 1. Environment Configuration
|
||||
- ✅ Updated `.env.example` with integration testing configuration
|
||||
- ✅ Added environment variables for:
|
||||
- n8n API credentials (`N8N_API_URL`, `N8N_API_KEY`)
|
||||
- Webhook workflow IDs (4 workflows for GET/POST/PUT/DELETE)
|
||||
- Test configuration (cleanup, tags, naming)
|
||||
- ✅ Included detailed setup instructions in comments
|
||||
|
||||
### 2. Directory Structure
|
||||
```
|
||||
tests/integration/n8n-api/
|
||||
├── workflows/ (empty - for Phase 2+)
|
||||
├── executions/ (empty - for Phase 2+)
|
||||
├── system/ (empty - for Phase 2+)
|
||||
├── scripts/
|
||||
│ └── cleanup-orphans.ts
|
||||
└── utils/
|
||||
├── credentials.ts
|
||||
├── n8n-client.ts
|
||||
├── test-context.ts
|
||||
├── cleanup-helpers.ts
|
||||
├── fixtures.ts
|
||||
├── factories.ts
|
||||
└── webhook-workflows.ts
|
||||
```
|
||||
|
||||
### 3. Core Utilities
|
||||
|
||||
#### `credentials.ts` (200 lines)
|
||||
- Environment-aware credential loading
|
||||
- Detects CI vs local environment automatically
|
||||
- Validation functions with helpful error messages
|
||||
- Non-throwing credential check functions
|
||||
|
||||
**Key Functions:**
|
||||
- `getN8nCredentials()` - Load credentials from .env or GitHub secrets
|
||||
- `validateCredentials()` - Ensure required credentials are present
|
||||
- `validateWebhookWorkflows()` - Check webhook workflow IDs with setup instructions
|
||||
- `hasCredentials()` - Non-throwing credential check
|
||||
- `hasWebhookWorkflows()` - Non-throwing webhook check
|
||||
|
||||
#### `n8n-client.ts` (45 lines)
|
||||
- Singleton n8n API client wrapper
|
||||
- Pre-configured with test credentials
|
||||
- Health check functionality
|
||||
|
||||
**Key Functions:**
|
||||
- `getTestN8nClient()` - Get/create configured API client
|
||||
- `resetTestN8nClient()` - Reset client instance
|
||||
- `isN8nApiAccessible()` - Check API connectivity
|
||||
|
||||
#### `test-context.ts` (120 lines)
|
||||
- Resource tracking for automatic cleanup
|
||||
- Test workflow naming utilities
|
||||
- Tag management
|
||||
|
||||
**Key Functions:**
|
||||
- `createTestContext()` - Create context for tracking resources
|
||||
- `TestContext.trackWorkflow()` - Track workflow for cleanup
|
||||
- `TestContext.trackExecution()` - Track execution for cleanup
|
||||
- `TestContext.cleanup()` - Delete all tracked resources
|
||||
- `createTestWorkflowName()` - Generate unique workflow names
|
||||
- `getTestTag()` - Get configured test tag
|
||||
|
||||
#### `cleanup-helpers.ts` (275 lines)
|
||||
- Multi-level cleanup strategies
|
||||
- Orphaned resource detection
|
||||
- Age-based execution cleanup
|
||||
- Tag-based workflow cleanup
|
||||
|
||||
**Key Functions:**
|
||||
- `cleanupOrphanedWorkflows()` - Find and delete test workflows
|
||||
- `cleanupOldExecutions()` - Delete executions older than X hours
|
||||
- `cleanupAllTestResources()` - Comprehensive cleanup
|
||||
- `cleanupWorkflowsByTag()` - Delete workflows by tag
|
||||
- `cleanupExecutionsByWorkflow()` - Delete workflow's executions
|
||||
|
||||
#### `fixtures.ts` (310 lines)
|
||||
- Pre-built workflow templates
|
||||
- All using FULL node type format (n8n-nodes-base.*)
|
||||
|
||||
**Available Fixtures:**
|
||||
- `SIMPLE_WEBHOOK_WORKFLOW` - Single webhook node
|
||||
- `SIMPLE_HTTP_WORKFLOW` - Webhook + HTTP Request
|
||||
- `MULTI_NODE_WORKFLOW` - Complex branching workflow
|
||||
- `ERROR_HANDLING_WORKFLOW` - Error output configuration
|
||||
- `AI_AGENT_WORKFLOW` - Langchain agent node
|
||||
- `EXPRESSION_WORKFLOW` - n8n expressions testing
|
||||
|
||||
**Helper Functions:**
|
||||
- `getFixture()` - Get fixture by name (with deep clone)
|
||||
- `createCustomWorkflow()` - Build custom workflow from nodes
|
||||
|
||||
#### `factories.ts` (315 lines)
|
||||
- Dynamic test data generation
|
||||
- Node builders with sensible defaults
|
||||
- Workflow composition helpers
|
||||
|
||||
**Node Factories:**
|
||||
- `createWebhookNode()` - Webhook node with customization
|
||||
- `createHttpRequestNode()` - HTTP Request node
|
||||
- `createSetNode()` - Set node with assignments
|
||||
- `createManualTriggerNode()` - Manual trigger node
|
||||
|
||||
**Connection Factories:**
|
||||
- `createConnection()` - Simple node connection
|
||||
- `createSequentialWorkflow()` - Auto-connected sequential nodes
|
||||
- `createParallelWorkflow()` - Trigger with parallel branches
|
||||
- `createErrorHandlingWorkflow()` - Workflow with error handling
|
||||
|
||||
**Utilities:**
|
||||
- `randomString()` - Generate random test data
|
||||
- `uniqueId()` - Unique IDs for testing
|
||||
- `createTestTags()` - Test workflow tags
|
||||
- `createWorkflowSettings()` - Common settings
|
||||
|
||||
#### `webhook-workflows.ts` (215 lines)
|
||||
- Webhook workflow configuration templates
|
||||
- Setup instructions generator
|
||||
- URL generation utilities
|
||||
|
||||
**Key Features:**
|
||||
- `WEBHOOK_WORKFLOW_CONFIGS` - Configurations for all 4 HTTP methods
|
||||
- `printSetupInstructions()` - Print detailed setup guide
|
||||
- `generateWebhookWorkflowJson()` - Generate workflow JSON
|
||||
- `exportAllWebhookWorkflows()` - Export all 4 configs
|
||||
- `getWebhookUrl()` - Get webhook URL for testing
|
||||
- `isValidWebhookWorkflow()` - Validate workflow structure
|
||||
|
||||
### 4. Scripts
|
||||
|
||||
#### `cleanup-orphans.ts` (40 lines)
|
||||
- Standalone cleanup script
|
||||
- Can be run manually or in CI
|
||||
- Comprehensive output logging
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
npm run test:cleanup:orphans
|
||||
```
|
||||
|
||||
### 5. npm Scripts
|
||||
Added to `package.json`:
|
||||
```json
|
||||
{
|
||||
"test:integration:n8n": "vitest run tests/integration/n8n-api",
|
||||
"test:cleanup:orphans": "tsx tests/integration/n8n-api/scripts/cleanup-orphans.ts"
|
||||
}
|
||||
```
|
||||
|
||||
## Code Quality
|
||||
|
||||
### TypeScript
|
||||
- ✅ All code passes `npm run typecheck`
|
||||
- ✅ All code compiles with `npm run build`
|
||||
- ✅ No TypeScript errors
|
||||
- ✅ Proper type annotations throughout
|
||||
|
||||
### Error Handling
|
||||
- ✅ Comprehensive error messages
|
||||
- ✅ Helpful setup instructions in error messages
|
||||
- ✅ Non-throwing validation functions where appropriate
|
||||
- ✅ Graceful handling of missing credentials
|
||||
|
||||
### Documentation
|
||||
- ✅ All functions have JSDoc comments
|
||||
- ✅ Usage examples in comments
|
||||
- ✅ Clear parameter descriptions
|
||||
- ✅ Return type documentation
|
||||
|
||||
## Files Created
|
||||
|
||||
### Documentation
|
||||
1. `docs/local/integration-testing-plan.md` (550 lines)
|
||||
2. `docs/local/integration-tests-phase1-summary.md` (this file)
|
||||
|
||||
### Code
|
||||
1. `.env.example` - Updated with test configuration (32 new lines)
|
||||
2. `package.json` - Added 2 npm scripts
|
||||
3. `tests/integration/n8n-api/utils/credentials.ts` (200 lines)
|
||||
4. `tests/integration/n8n-api/utils/n8n-client.ts` (45 lines)
|
||||
5. `tests/integration/n8n-api/utils/test-context.ts` (120 lines)
|
||||
6. `tests/integration/n8n-api/utils/cleanup-helpers.ts` (275 lines)
|
||||
7. `tests/integration/n8n-api/utils/fixtures.ts` (310 lines)
|
||||
8. `tests/integration/n8n-api/utils/factories.ts` (315 lines)
|
||||
9. `tests/integration/n8n-api/utils/webhook-workflows.ts` (215 lines)
|
||||
10. `tests/integration/n8n-api/scripts/cleanup-orphans.ts` (40 lines)
|
||||
|
||||
**Total New Code:** ~1,520 lines of production-ready TypeScript
|
||||
|
||||
## Next Steps (Phase 2)
|
||||
|
||||
Phase 2 will implement the first actual integration tests:
|
||||
- Create workflow creation tests (10+ scenarios)
|
||||
- Test P0 bug fix (SHORT vs FULL node types)
|
||||
- Test workflow retrieval
|
||||
- Test workflow deletion
|
||||
|
||||
**Branch:** `feat/integration-tests-workflow-creation`
|
||||
|
||||
## Prerequisites for Running Tests
|
||||
|
||||
Before running integration tests, you need to:
|
||||
|
||||
1. **Set up n8n instance:**
|
||||
- Local: `npx n8n start`
|
||||
- Or use cloud/self-hosted n8n
|
||||
|
||||
2. **Configure credentials in `.env`:**
|
||||
```bash
|
||||
N8N_API_URL=http://localhost:5678
|
||||
N8N_API_KEY=<your-api-key>
|
||||
```
|
||||
|
||||
3. **Create 4 webhook workflows manually:**
|
||||
- One for each HTTP method (GET, POST, PUT, DELETE)
|
||||
- Activate each workflow in n8n UI
|
||||
- Set workflow IDs in `.env`:
|
||||
```bash
|
||||
N8N_TEST_WEBHOOK_GET_ID=<workflow-id>
|
||||
N8N_TEST_WEBHOOK_POST_ID=<workflow-id>
|
||||
N8N_TEST_WEBHOOK_PUT_ID=<workflow-id>
|
||||
N8N_TEST_WEBHOOK_DELETE_ID=<workflow-id>
|
||||
```
|
||||
|
||||
See `docs/local/integration-testing-plan.md` for detailed setup instructions.
|
||||
|
||||
## Success Metrics
|
||||
|
||||
Phase 1 Success Criteria - ALL MET:
|
||||
- ✅ All utilities implemented and tested
|
||||
- ✅ TypeScript compiles without errors
|
||||
- ✅ Code follows project conventions
|
||||
- ✅ Comprehensive documentation
|
||||
- ✅ Environment configuration complete
|
||||
- ✅ Cleanup infrastructure in place
|
||||
- ✅ Ready for Phase 2 test implementation
|
||||
|
||||
## Lessons Learned
|
||||
|
||||
1. **N8nApiClient Constructor:** Uses config object, not separate parameters
|
||||
2. **Cursor Handling:** n8n API returns `null` for no more pages, need to convert to `undefined`
|
||||
3. **Workflow ID Validation:** Some workflows might have undefined IDs, need null checks
|
||||
4. **Connection Types:** Error connections need explicit typing to avoid TypeScript errors
|
||||
5. **Webhook Activation:** Cannot be done via API, must be manual - hence pre-activated workflow requirement
|
||||
|
||||
## Time Invested
|
||||
|
||||
Phase 1 actual time: ~2 hours (estimated 2-3 days in plan)
|
||||
- Faster than expected due to clear architecture and reusable patterns
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.14.4",
|
||||
"version": "2.15.3",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
@@ -31,6 +31,8 @@
|
||||
"test:watch": "vitest watch",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run --config vitest.config.integration.ts",
|
||||
"test:integration:n8n": "vitest run tests/integration/n8n-api",
|
||||
"test:cleanup:orphans": "tsx tests/integration/n8n-api/scripts/cleanup-orphans.ts",
|
||||
"test:e2e": "vitest run tests/e2e",
|
||||
"lint": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
@@ -38,6 +40,7 @@
|
||||
"update:n8n:check": "node scripts/update-n8n-deps.js --dry-run",
|
||||
"fetch:templates": "node dist/scripts/fetch-templates.js",
|
||||
"fetch:templates:update": "node dist/scripts/fetch-templates.js --update",
|
||||
"fetch:templates:extract": "node dist/scripts/fetch-templates.js --extract-only",
|
||||
"fetch:templates:robust": "node dist/scripts/fetch-templates-robust.js",
|
||||
"prebuild:fts5": "npx tsx scripts/prebuild-fts5.ts",
|
||||
"test:templates": "node dist/scripts/test-templates.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp-runtime",
|
||||
"version": "2.14.3",
|
||||
"version": "2.15.1",
|
||||
"description": "n8n MCP Server Runtime Dependencies Only",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
|
||||
@@ -23,7 +23,7 @@ async function testIntegration() {
|
||||
|
||||
// Track errors
|
||||
console.log('Tracking errors...');
|
||||
telemetry.trackError('ValidationError', 'workflow_validation', 'validate_workflow');
|
||||
telemetry.trackError('ValidationError', 'workflow_validation', 'validate_workflow', 'Required field missing: nodes array is empty');
|
||||
|
||||
// Track a test workflow
|
||||
console.log('Tracking workflow creation...');
|
||||
|
||||
59
src/database/migrations/add-template-node-configs.sql
Normal file
59
src/database/migrations/add-template-node-configs.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- Migration: Add template_node_configs table
|
||||
-- Run during `npm run rebuild` or `npm run fetch:templates`
|
||||
-- This migration is idempotent - safe to run multiple times
|
||||
|
||||
-- Create table if it doesn't exist
|
||||
CREATE TABLE IF NOT EXISTS template_node_configs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
node_type TEXT NOT NULL,
|
||||
template_id INTEGER NOT NULL,
|
||||
template_name TEXT NOT NULL,
|
||||
template_views INTEGER DEFAULT 0,
|
||||
|
||||
-- Node configuration (extracted from workflow)
|
||||
node_name TEXT, -- Node name in workflow (e.g., "HTTP Request")
|
||||
parameters_json TEXT NOT NULL, -- JSON: node.parameters
|
||||
credentials_json TEXT, -- JSON: node.credentials (if present)
|
||||
|
||||
-- Pre-calculated metadata for filtering
|
||||
has_credentials INTEGER DEFAULT 0,
|
||||
has_expressions INTEGER DEFAULT 0, -- Contains {{...}} or $json/$node
|
||||
complexity TEXT CHECK(complexity IN ('simple', 'medium', 'complex')),
|
||||
use_cases TEXT, -- JSON array from template.metadata.use_cases
|
||||
|
||||
-- Pre-calculated ranking (1 = best, 2 = second best, etc.)
|
||||
rank INTEGER DEFAULT 0,
|
||||
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Create indexes if they don't exist
|
||||
CREATE INDEX IF NOT EXISTS idx_config_node_type_rank
|
||||
ON template_node_configs(node_type, rank);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_config_complexity
|
||||
ON template_node_configs(node_type, complexity, rank);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_config_auth
|
||||
ON template_node_configs(node_type, has_credentials, rank);
|
||||
|
||||
-- Create view if it doesn't exist
|
||||
CREATE VIEW IF NOT EXISTS ranked_node_configs AS
|
||||
SELECT
|
||||
node_type,
|
||||
template_name,
|
||||
template_views,
|
||||
parameters_json,
|
||||
credentials_json,
|
||||
has_credentials,
|
||||
has_expressions,
|
||||
complexity,
|
||||
use_cases,
|
||||
rank
|
||||
FROM template_node_configs
|
||||
WHERE rank <= 5 -- Top 5 per node type
|
||||
ORDER BY node_type, rank;
|
||||
|
||||
-- Note: Actual data population is handled by the fetch-templates script
|
||||
-- This migration only creates the schema
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DatabaseAdapter } from './database-adapter';
|
||||
import { ParsedNode } from '../parsers/node-parser';
|
||||
import { SQLiteStorageService } from '../services/sqlite-storage-service';
|
||||
import { NodeTypeNormalizer } from '../utils/node-type-normalizer';
|
||||
|
||||
export class NodeRepository {
|
||||
private db: DatabaseAdapter;
|
||||
@@ -50,33 +51,30 @@ export class NodeRepository {
|
||||
|
||||
/**
|
||||
* Get node with proper JSON deserialization
|
||||
* Automatically normalizes node type to full form for consistent lookups
|
||||
*/
|
||||
getNode(nodeType: string): any {
|
||||
// Normalize to full form first for consistent lookups
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
|
||||
const row = this.db.prepare(`
|
||||
SELECT * FROM nodes WHERE node_type = ?
|
||||
`).get(nodeType) as any;
|
||||
|
||||
`).get(normalizedType) as any;
|
||||
|
||||
// Fallback: try original type if normalization didn't help (e.g., community nodes)
|
||||
if (!row && normalizedType !== nodeType) {
|
||||
const originalRow = this.db.prepare(`
|
||||
SELECT * FROM nodes WHERE node_type = ?
|
||||
`).get(nodeType) as any;
|
||||
|
||||
if (originalRow) {
|
||||
return this.parseNodeRow(originalRow);
|
||||
}
|
||||
}
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
nodeType: row.node_type,
|
||||
displayName: row.display_name,
|
||||
description: row.description,
|
||||
category: row.category,
|
||||
developmentStyle: row.development_style,
|
||||
package: row.package_name,
|
||||
isAITool: Number(row.is_ai_tool) === 1,
|
||||
isTrigger: Number(row.is_trigger) === 1,
|
||||
isWebhook: Number(row.is_webhook) === 1,
|
||||
isVersioned: Number(row.is_versioned) === 1,
|
||||
version: row.version,
|
||||
properties: this.safeJsonParse(row.properties_schema, []),
|
||||
operations: this.safeJsonParse(row.operations, []),
|
||||
credentials: this.safeJsonParse(row.credentials_required, []),
|
||||
hasDocumentation: !!row.documentation,
|
||||
outputs: row.outputs ? this.safeJsonParse(row.outputs, null) : null,
|
||||
outputNames: row.output_names ? this.safeJsonParse(row.output_names, null) : null
|
||||
};
|
||||
|
||||
return this.parseNodeRow(row);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
0
src/database/nodes.db
Normal file
0
src/database/nodes.db
Normal file
@@ -53,5 +53,60 @@ CREATE INDEX IF NOT EXISTS idx_template_updated ON templates(updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_template_name ON templates(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_template_metadata ON templates(metadata_generated_at);
|
||||
|
||||
-- Pre-extracted node configurations from templates
|
||||
-- This table stores the top node configurations from popular templates
|
||||
-- Provides fast access to real-world configuration examples
|
||||
CREATE TABLE IF NOT EXISTS template_node_configs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
node_type TEXT NOT NULL,
|
||||
template_id INTEGER NOT NULL,
|
||||
template_name TEXT NOT NULL,
|
||||
template_views INTEGER DEFAULT 0,
|
||||
|
||||
-- Node configuration (extracted from workflow)
|
||||
node_name TEXT, -- Node name in workflow (e.g., "HTTP Request")
|
||||
parameters_json TEXT NOT NULL, -- JSON: node.parameters
|
||||
credentials_json TEXT, -- JSON: node.credentials (if present)
|
||||
|
||||
-- Pre-calculated metadata for filtering
|
||||
has_credentials INTEGER DEFAULT 0,
|
||||
has_expressions INTEGER DEFAULT 0, -- Contains {{...}} or $json/$node
|
||||
complexity TEXT CHECK(complexity IN ('simple', 'medium', 'complex')),
|
||||
use_cases TEXT, -- JSON array from template.metadata.use_cases
|
||||
|
||||
-- Pre-calculated ranking (1 = best, 2 = second best, etc.)
|
||||
rank INTEGER DEFAULT 0,
|
||||
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (template_id) REFERENCES templates(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Indexes for fast queries
|
||||
CREATE INDEX IF NOT EXISTS idx_config_node_type_rank
|
||||
ON template_node_configs(node_type, rank);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_config_complexity
|
||||
ON template_node_configs(node_type, complexity, rank);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_config_auth
|
||||
ON template_node_configs(node_type, has_credentials, rank);
|
||||
|
||||
-- View for easy querying of top configs
|
||||
CREATE VIEW IF NOT EXISTS ranked_node_configs AS
|
||||
SELECT
|
||||
node_type,
|
||||
template_name,
|
||||
template_views,
|
||||
parameters_json,
|
||||
credentials_json,
|
||||
has_credentials,
|
||||
has_expressions,
|
||||
complexity,
|
||||
use_cases,
|
||||
rank
|
||||
FROM template_node_configs
|
||||
WHERE rank <= 5 -- Top 5 per node type
|
||||
ORDER BY node_type, rank;
|
||||
|
||||
-- Note: FTS5 tables are created conditionally at runtime if FTS5 is supported
|
||||
-- See template-repository.ts initializeFTS5() method
|
||||
@@ -89,10 +89,6 @@ export class MCPEngine {
|
||||
return this.repository.searchNodeProperties(args.nodeType, args.query, args.maxResults || 20);
|
||||
}
|
||||
|
||||
async getNodeForTask(args: any) {
|
||||
return TaskTemplates.getTaskTemplate(args.task);
|
||||
}
|
||||
|
||||
async listAITools(args: any) {
|
||||
return this.repository.getAIToolNodes();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
WorkflowConnection,
|
||||
ExecutionStatus,
|
||||
WebhookRequest,
|
||||
McpToolResponse
|
||||
McpToolResponse,
|
||||
ExecutionFilterOptions,
|
||||
ExecutionMode
|
||||
} from '../types/n8n-api';
|
||||
import {
|
||||
validateWorkflowStructure,
|
||||
@@ -16,7 +18,9 @@ import {
|
||||
import {
|
||||
N8nApiError,
|
||||
N8nNotFoundError,
|
||||
getUserFriendlyErrorMessage
|
||||
getUserFriendlyErrorMessage,
|
||||
formatExecutionError,
|
||||
formatNoExecutionError
|
||||
} from '../utils/n8n-errors';
|
||||
import { logger } from '../utils/logger';
|
||||
import { z } from 'zod';
|
||||
@@ -24,6 +28,7 @@ import { WorkflowValidator } from '../services/workflow-validator';
|
||||
import { EnhancedConfigValidator } from '../services/enhanced-config-validator';
|
||||
import { NodeRepository } from '../database/node-repository';
|
||||
import { InstanceContext, validateInstanceContext } from '../types/instance-context';
|
||||
import { NodeTypeNormalizer } from '../utils/node-type-normalizer';
|
||||
import { WorkflowAutoFixer, AutoFixConfig } from '../services/workflow-auto-fixer';
|
||||
import { ExpressionFormatValidator } from '../services/expression-format-validator';
|
||||
import { handleUpdatePartialWorkflow } from './handlers-workflow-diff';
|
||||
@@ -36,6 +41,7 @@ import {
|
||||
withRetry,
|
||||
getCacheStatistics
|
||||
} from '../utils/cache-utils';
|
||||
import { processExecution } from '../services/execution-processor';
|
||||
|
||||
// Singleton n8n API client instance (backward compatibility)
|
||||
let defaultApiClient: N8nApiClient | null = null;
|
||||
@@ -277,8 +283,34 @@ export async function handleCreateWorkflow(args: unknown, context?: InstanceCont
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const input = createWorkflowSchema.parse(args);
|
||||
|
||||
// Validate workflow structure
|
||||
|
||||
// Proactively detect SHORT form node types (common mistake)
|
||||
const shortFormErrors: string[] = [];
|
||||
input.nodes?.forEach((node: any, index: number) => {
|
||||
if (node.type?.startsWith('nodes-base.') || node.type?.startsWith('nodes-langchain.')) {
|
||||
const fullForm = node.type.startsWith('nodes-base.')
|
||||
? node.type.replace('nodes-base.', 'n8n-nodes-base.')
|
||||
: node.type.replace('nodes-langchain.', '@n8n/n8n-nodes-langchain.');
|
||||
shortFormErrors.push(
|
||||
`Node ${index} ("${node.name}") uses SHORT form "${node.type}". ` +
|
||||
`The n8n API requires FULL form. Change to "${fullForm}"`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (shortFormErrors.length > 0) {
|
||||
telemetry.trackWorkflowCreation(input, false);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Node type format error: n8n API requires FULL form node types',
|
||||
details: {
|
||||
errors: shortFormErrors,
|
||||
hint: 'Use n8n-nodes-base.* instead of nodes-base.* for standard nodes'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Validate workflow structure (n8n API expects FULL form: n8n-nodes-base.*)
|
||||
const errors = validateWorkflowStructure(input);
|
||||
if (errors.length > 0) {
|
||||
// Track validation failure
|
||||
@@ -291,7 +323,7 @@ export async function handleCreateWorkflow(args: unknown, context?: InstanceCont
|
||||
};
|
||||
}
|
||||
|
||||
// Create workflow
|
||||
// Create workflow (n8n API expects node types in FULL form)
|
||||
const workflow = await client.createWorkflow(input);
|
||||
|
||||
// Track successful workflow creation
|
||||
@@ -517,12 +549,12 @@ export async function handleUpdateWorkflow(args: unknown, context?: InstanceCont
|
||||
const client = ensureApiConfigured(context);
|
||||
const input = updateWorkflowSchema.parse(args);
|
||||
const { id, ...updateData } = input;
|
||||
|
||||
|
||||
// If nodes/connections are being updated, validate the structure
|
||||
if (updateData.nodes || updateData.connections) {
|
||||
// Fetch current workflow if only partial update
|
||||
let fullWorkflow = updateData as Partial<Workflow>;
|
||||
|
||||
|
||||
if (!updateData.nodes || !updateData.connections) {
|
||||
const current = await client.getWorkflow(id);
|
||||
fullWorkflow = {
|
||||
@@ -530,7 +562,8 @@ export async function handleUpdateWorkflow(args: unknown, context?: InstanceCont
|
||||
...updateData
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Validate workflow structure (n8n API expects FULL form: n8n-nodes-base.*)
|
||||
const errors = validateWorkflowStructure(fullWorkflow);
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
@@ -939,7 +972,7 @@ export async function handleTriggerWebhookWorkflow(args: unknown, context?: Inst
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const input = triggerWebhookSchema.parse(args);
|
||||
|
||||
|
||||
const webhookRequest: WebhookRequest = {
|
||||
webhookUrl: input.webhookUrl,
|
||||
httpMethod: input.httpMethod || 'POST',
|
||||
@@ -947,9 +980,9 @@ export async function handleTriggerWebhookWorkflow(args: unknown, context?: Inst
|
||||
headers: input.headers,
|
||||
waitForResponse: input.waitForResponse ?? true
|
||||
};
|
||||
|
||||
|
||||
const response = await client.triggerWebhook(webhookRequest);
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
@@ -963,8 +996,35 @@ export async function handleTriggerWebhookWorkflow(args: unknown, context?: Inst
|
||||
details: { errors: error.errors }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (error instanceof N8nApiError) {
|
||||
// Try to extract execution context from error response
|
||||
const errorData = error.details as any;
|
||||
const executionId = errorData?.executionId || errorData?.id || errorData?.execution?.id;
|
||||
const workflowId = errorData?.workflowId || errorData?.workflow?.id;
|
||||
|
||||
// If we have execution ID, provide specific guidance with n8n_get_execution
|
||||
if (executionId) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatExecutionError(executionId, workflowId),
|
||||
code: error.code,
|
||||
executionId,
|
||||
workflowId: workflowId || undefined
|
||||
};
|
||||
}
|
||||
|
||||
// No execution ID available - workflow likely didn't start
|
||||
// Provide guidance to check recent executions
|
||||
if (error.code === 'SERVER_ERROR' || error.statusCode && error.statusCode >= 500) {
|
||||
return {
|
||||
success: false,
|
||||
error: formatNoExecutionError(),
|
||||
code: error.code
|
||||
};
|
||||
}
|
||||
|
||||
// For other errors (auth, validation, etc), use standard message
|
||||
return {
|
||||
success: false,
|
||||
error: getUserFriendlyErrorMessage(error),
|
||||
@@ -972,7 +1032,7 @@ export async function handleTriggerWebhookWorkflow(args: unknown, context?: Inst
|
||||
details: error.details as Record<string, unknown> | undefined
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
@@ -983,16 +1043,72 @@ export async function handleTriggerWebhookWorkflow(args: unknown, context?: Inst
|
||||
export async function handleGetExecution(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { id, includeData } = z.object({
|
||||
|
||||
// Parse and validate input with new parameters
|
||||
const schema = z.object({
|
||||
id: z.string(),
|
||||
// New filtering parameters
|
||||
mode: z.enum(['preview', 'summary', 'filtered', 'full']).optional(),
|
||||
nodeNames: z.array(z.string()).optional(),
|
||||
itemsLimit: z.number().optional(),
|
||||
includeInputData: z.boolean().optional(),
|
||||
// Legacy parameter (backward compatibility)
|
||||
includeData: z.boolean().optional()
|
||||
}).parse(args);
|
||||
|
||||
const execution = await client.getExecution(id, includeData || false);
|
||||
|
||||
});
|
||||
|
||||
const params = schema.parse(args);
|
||||
const { id, mode, nodeNames, itemsLimit, includeInputData, includeData } = params;
|
||||
|
||||
/**
|
||||
* Map legacy includeData parameter to mode for backward compatibility
|
||||
*
|
||||
* Legacy behavior:
|
||||
* - includeData: undefined -> minimal execution summary (no data)
|
||||
* - includeData: false -> minimal execution summary (no data)
|
||||
* - includeData: true -> full execution data
|
||||
*
|
||||
* New behavior mapping:
|
||||
* - includeData: undefined -> no mode (minimal)
|
||||
* - includeData: false -> no mode (minimal)
|
||||
* - includeData: true -> mode: 'summary' (2 items per node, not full)
|
||||
*
|
||||
* Note: Legacy true behavior returned ALL data, which could exceed token limits.
|
||||
* New behavior caps at 2 items for safety. Users can use mode: 'full' for old behavior.
|
||||
*/
|
||||
let effectiveMode = mode;
|
||||
if (!effectiveMode && includeData !== undefined) {
|
||||
effectiveMode = includeData ? 'summary' : undefined;
|
||||
}
|
||||
|
||||
// Determine if we need to fetch full data from API
|
||||
// We fetch full data if any mode is specified (including preview) or legacy includeData is true
|
||||
// Preview mode needs the data to analyze structure and generate recommendations
|
||||
const fetchFullData = effectiveMode !== undefined || includeData === true;
|
||||
|
||||
// Fetch execution from n8n API
|
||||
const execution = await client.getExecution(id, fetchFullData);
|
||||
|
||||
// If no filtering options specified, return original execution (backward compatibility)
|
||||
if (!effectiveMode && !nodeNames && itemsLimit === undefined) {
|
||||
return {
|
||||
success: true,
|
||||
data: execution
|
||||
};
|
||||
}
|
||||
|
||||
// Apply filtering using ExecutionProcessor
|
||||
const filterOptions: ExecutionFilterOptions = {
|
||||
mode: effectiveMode,
|
||||
nodeNames,
|
||||
itemsLimit,
|
||||
includeInputData
|
||||
};
|
||||
|
||||
const processedExecution = processExecution(execution, filterOptions);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: execution
|
||||
data: processedExecution
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
@@ -1002,7 +1118,7 @@ export async function handleGetExecution(args: unknown, context?: InstanceContex
|
||||
details: { errors: error.errors }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
if (error instanceof N8nApiError) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -1010,7 +1126,7 @@ export async function handleGetExecution(args: unknown, context?: InstanceContex
|
||||
code: error.code
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
|
||||
@@ -27,7 +27,8 @@ import * as n8nHandlers from './handlers-n8n-manager';
|
||||
import { handleUpdatePartialWorkflow } from './handlers-workflow-diff';
|
||||
import { getToolDocumentation, getToolsOverview } from './tools-documentation';
|
||||
import { PROJECT_VERSION } from '../utils/version';
|
||||
import { normalizeNodeType, getNodeTypeAlternatives, getWorkflowNodeType } from '../utils/node-utils';
|
||||
import { getNodeTypeAlternatives, getWorkflowNodeType } from '../utils/node-utils';
|
||||
import { NodeTypeNormalizer } from '../utils/node-type-normalizer';
|
||||
import { ToolValidation, Validator, ValidationError } from '../utils/validation-schemas';
|
||||
import {
|
||||
negotiateProtocolVersion,
|
||||
@@ -397,7 +398,8 @@ export class N8NDocumentationMCPServer {
|
||||
telemetry.trackError(
|
||||
error instanceof Error ? error.constructor.name : 'UnknownError',
|
||||
`tool_execution`,
|
||||
name
|
||||
name,
|
||||
errorMessage
|
||||
);
|
||||
|
||||
// Track tool sequence even for errors
|
||||
@@ -712,7 +714,7 @@ export class N8NDocumentationMCPServer {
|
||||
this.validateToolParams(name, args, ['query']);
|
||||
// Convert limit to number if provided, otherwise use default
|
||||
const limit = args.limit !== undefined ? Number(args.limit) || 20 : 20;
|
||||
return this.searchNodes(args.query, limit, { mode: args.mode });
|
||||
return this.searchNodes(args.query, limit, { mode: args.mode, includeExamples: args.includeExamples });
|
||||
case 'list_ai_tools':
|
||||
// No required parameters
|
||||
return this.listAITools();
|
||||
@@ -724,14 +726,11 @@ export class N8NDocumentationMCPServer {
|
||||
return this.getDatabaseStatistics();
|
||||
case 'get_node_essentials':
|
||||
this.validateToolParams(name, args, ['nodeType']);
|
||||
return this.getNodeEssentials(args.nodeType);
|
||||
return this.getNodeEssentials(args.nodeType, args.includeExamples);
|
||||
case 'search_node_properties':
|
||||
this.validateToolParams(name, args, ['nodeType', 'query']);
|
||||
const maxResults = args.maxResults !== undefined ? Number(args.maxResults) || 20 : 20;
|
||||
return this.searchNodeProperties(args.nodeType, args.query, maxResults);
|
||||
case 'get_node_for_task':
|
||||
this.validateToolParams(name, args, ['task']);
|
||||
return this.getNodeForTask(args.task);
|
||||
case 'list_tasks':
|
||||
// No required parameters
|
||||
return this.listTasks(args.category);
|
||||
@@ -966,9 +965,9 @@ export class N8NDocumentationMCPServer {
|
||||
private async getNodeInfo(nodeType: string): Promise<any> {
|
||||
await this.ensureInitialized();
|
||||
if (!this.repository) throw new Error('Repository not initialized');
|
||||
|
||||
// First try with normalized type
|
||||
const normalizedType = normalizeNodeType(nodeType);
|
||||
|
||||
// First try with normalized type (repository will also normalize internally)
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
let node = this.repository.getNode(normalizedType);
|
||||
|
||||
if (!node && normalizedType !== nodeType) {
|
||||
@@ -1029,11 +1028,12 @@ export class N8NDocumentationMCPServer {
|
||||
}
|
||||
|
||||
private async searchNodes(
|
||||
query: string,
|
||||
query: string,
|
||||
limit: number = 20,
|
||||
options?: {
|
||||
options?: {
|
||||
mode?: 'OR' | 'AND' | 'FUZZY';
|
||||
includeSource?: boolean;
|
||||
includeExamples?: boolean;
|
||||
}
|
||||
): Promise<any> {
|
||||
await this.ensureInitialized();
|
||||
@@ -1059,16 +1059,23 @@ export class N8NDocumentationMCPServer {
|
||||
|
||||
if (ftsExists) {
|
||||
// Use FTS5 search with normalized query
|
||||
return this.searchNodesFTS(normalizedQuery, limit, searchMode);
|
||||
logger.debug(`Using FTS5 search with includeExamples=${options?.includeExamples}`);
|
||||
return this.searchNodesFTS(normalizedQuery, limit, searchMode, options);
|
||||
} else {
|
||||
// Fallback to LIKE search with normalized query
|
||||
return this.searchNodesLIKE(normalizedQuery, limit);
|
||||
logger.debug('Using LIKE search (no FTS5)');
|
||||
return this.searchNodesLIKE(normalizedQuery, limit, options);
|
||||
}
|
||||
}
|
||||
|
||||
private async searchNodesFTS(query: string, limit: number, mode: 'OR' | 'AND' | 'FUZZY'): Promise<any> {
|
||||
|
||||
private async searchNodesFTS(
|
||||
query: string,
|
||||
limit: number,
|
||||
mode: 'OR' | 'AND' | 'FUZZY',
|
||||
options?: { includeSource?: boolean; includeExamples?: boolean; }
|
||||
): Promise<any> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
|
||||
// Clean and prepare the query
|
||||
const cleanedQuery = query.trim();
|
||||
if (!cleanedQuery) {
|
||||
@@ -1167,12 +1174,40 @@ export class N8NDocumentationMCPServer {
|
||||
})),
|
||||
totalCount: scoredNodes.length
|
||||
};
|
||||
|
||||
|
||||
// Only include mode if it's not the default
|
||||
if (mode !== 'OR') {
|
||||
result.mode = mode;
|
||||
}
|
||||
|
||||
// Add examples if requested
|
||||
if (options && options.includeExamples) {
|
||||
try {
|
||||
for (const nodeResult of result.results) {
|
||||
const examples = this.db!.prepare(`
|
||||
SELECT
|
||||
parameters_json,
|
||||
template_name,
|
||||
template_views
|
||||
FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
ORDER BY rank
|
||||
LIMIT 2
|
||||
`).all(nodeResult.workflowNodeType) as any[];
|
||||
|
||||
if (examples.length > 0) {
|
||||
nodeResult.examples = examples.map((ex: any) => ({
|
||||
configuration: JSON.parse(ex.parameters_json),
|
||||
template: ex.template_name,
|
||||
views: ex.template_views
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to add examples:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Track search query telemetry
|
||||
telemetry.trackSearchQuery(query, scoredNodes.length, mode ?? 'OR');
|
||||
|
||||
@@ -1349,24 +1384,28 @@ export class N8NDocumentationMCPServer {
|
||||
return dp[m][n];
|
||||
}
|
||||
|
||||
private async searchNodesLIKE(query: string, limit: number): Promise<any> {
|
||||
private async searchNodesLIKE(
|
||||
query: string,
|
||||
limit: number,
|
||||
options?: { includeSource?: boolean; includeExamples?: boolean; }
|
||||
): Promise<any> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
|
||||
// This is the existing LIKE-based implementation
|
||||
// Handle exact phrase searches with quotes
|
||||
if (query.startsWith('"') && query.endsWith('"')) {
|
||||
const exactPhrase = query.slice(1, -1);
|
||||
const nodes = this.db!.prepare(`
|
||||
SELECT * FROM nodes
|
||||
SELECT * FROM nodes
|
||||
WHERE node_type LIKE ? OR display_name LIKE ? OR description LIKE ?
|
||||
LIMIT ?
|
||||
`).all(`%${exactPhrase}%`, `%${exactPhrase}%`, `%${exactPhrase}%`, limit * 3) as NodeRow[];
|
||||
|
||||
|
||||
// Apply relevance ranking for exact phrase search
|
||||
const rankedNodes = this.rankSearchResults(nodes, exactPhrase, limit);
|
||||
|
||||
return {
|
||||
query,
|
||||
|
||||
const result: any = {
|
||||
query,
|
||||
results: rankedNodes.map(node => ({
|
||||
nodeType: node.node_type,
|
||||
workflowNodeType: getWorkflowNodeType(node.package_name, node.node_type),
|
||||
@@ -1374,9 +1413,39 @@ export class N8NDocumentationMCPServer {
|
||||
description: node.description,
|
||||
category: node.category,
|
||||
package: node.package_name
|
||||
})),
|
||||
totalCount: rankedNodes.length
|
||||
})),
|
||||
totalCount: rankedNodes.length
|
||||
};
|
||||
|
||||
// Add examples if requested
|
||||
if (options?.includeExamples) {
|
||||
for (const nodeResult of result.results) {
|
||||
try {
|
||||
const examples = this.db!.prepare(`
|
||||
SELECT
|
||||
parameters_json,
|
||||
template_name,
|
||||
template_views
|
||||
FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
ORDER BY rank
|
||||
LIMIT 2
|
||||
`).all(nodeResult.workflowNodeType) as any[];
|
||||
|
||||
if (examples.length > 0) {
|
||||
nodeResult.examples = examples.map((ex: any) => ({
|
||||
configuration: JSON.parse(ex.parameters_json),
|
||||
template: ex.template_name,
|
||||
views: ex.template_views
|
||||
}));
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn(`Failed to fetch examples for ${nodeResult.nodeType}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Split into words for normal search
|
||||
@@ -1403,8 +1472,8 @@ export class N8NDocumentationMCPServer {
|
||||
|
||||
// Apply relevance ranking
|
||||
const rankedNodes = this.rankSearchResults(nodes, query, limit);
|
||||
|
||||
return {
|
||||
|
||||
const result: any = {
|
||||
query,
|
||||
results: rankedNodes.map(node => ({
|
||||
nodeType: node.node_type,
|
||||
@@ -1416,6 +1485,36 @@ export class N8NDocumentationMCPServer {
|
||||
})),
|
||||
totalCount: rankedNodes.length
|
||||
};
|
||||
|
||||
// Add examples if requested
|
||||
if (options?.includeExamples) {
|
||||
for (const nodeResult of result.results) {
|
||||
try {
|
||||
const examples = this.db!.prepare(`
|
||||
SELECT
|
||||
parameters_json,
|
||||
template_name,
|
||||
template_views
|
||||
FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
ORDER BY rank
|
||||
LIMIT 2
|
||||
`).all(nodeResult.workflowNodeType) as any[];
|
||||
|
||||
if (examples.length > 0) {
|
||||
nodeResult.examples = examples.map((ex: any) => ({
|
||||
configuration: JSON.parse(ex.parameters_json),
|
||||
template: ex.template_name,
|
||||
views: ex.template_views
|
||||
}));
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn(`Failed to fetch examples for ${nodeResult.nodeType}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private calculateRelevance(node: NodeRow, query: string): string {
|
||||
@@ -1604,9 +1703,9 @@ export class N8NDocumentationMCPServer {
|
||||
private async getNodeDocumentation(nodeType: string): Promise<any> {
|
||||
await this.ensureInitialized();
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
|
||||
// First try with normalized type
|
||||
const normalizedType = normalizeNodeType(nodeType);
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
let node = this.db!.prepare(`
|
||||
SELECT node_type, display_name, documentation, description
|
||||
FROM nodes
|
||||
@@ -1732,18 +1831,18 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
||||
};
|
||||
}
|
||||
|
||||
private async getNodeEssentials(nodeType: string): Promise<any> {
|
||||
private async getNodeEssentials(nodeType: string, includeExamples?: boolean): Promise<any> {
|
||||
await this.ensureInitialized();
|
||||
if (!this.repository) throw new Error('Repository not initialized');
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = `essentials:${nodeType}`;
|
||||
|
||||
// Check cache first (cache key includes includeExamples)
|
||||
const cacheKey = `essentials:${nodeType}:${includeExamples ? 'withExamples' : 'basic'}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
// Get the full node information
|
||||
// First try with normalized type
|
||||
const normalizedType = normalizeNodeType(nodeType);
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
let node = this.repository.getNode(normalizedType);
|
||||
|
||||
if (!node && normalizedType !== nodeType) {
|
||||
@@ -1804,20 +1903,66 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
||||
developmentStyle: node.developmentStyle ?? 'programmatic'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Add examples from templates if requested
|
||||
if (includeExamples) {
|
||||
try {
|
||||
const fullNodeType = getWorkflowNodeType(node.package ?? 'n8n-nodes-base', node.nodeType);
|
||||
const examples = this.db!.prepare(`
|
||||
SELECT
|
||||
parameters_json,
|
||||
template_name,
|
||||
template_views,
|
||||
complexity,
|
||||
use_cases,
|
||||
has_credentials,
|
||||
has_expressions
|
||||
FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
ORDER BY rank
|
||||
LIMIT 3
|
||||
`).all(fullNodeType) as any[];
|
||||
|
||||
if (examples.length > 0) {
|
||||
(result as any).examples = examples.map((ex: any) => ({
|
||||
configuration: JSON.parse(ex.parameters_json),
|
||||
source: {
|
||||
template: ex.template_name,
|
||||
views: ex.template_views,
|
||||
complexity: ex.complexity
|
||||
},
|
||||
useCases: ex.use_cases ? JSON.parse(ex.use_cases).slice(0, 2) : [],
|
||||
metadata: {
|
||||
hasCredentials: ex.has_credentials === 1,
|
||||
hasExpressions: ex.has_expressions === 1
|
||||
}
|
||||
}));
|
||||
|
||||
(result as any).examplesCount = examples.length;
|
||||
} else {
|
||||
(result as any).examples = [];
|
||||
(result as any).examplesCount = 0;
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.warn(`Failed to fetch examples for ${nodeType}:`, error.message);
|
||||
(result as any).examples = [];
|
||||
(result as any).examplesCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for 1 hour
|
||||
this.cache.set(cacheKey, result, 3600);
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async searchNodeProperties(nodeType: string, query: string, maxResults: number = 20): Promise<any> {
|
||||
await this.ensureInitialized();
|
||||
if (!this.repository) throw new Error('Repository not initialized');
|
||||
|
||||
|
||||
// Get the node
|
||||
// First try with normalized type
|
||||
const normalizedType = normalizeNodeType(nodeType);
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
let node = this.repository.getNode(normalizedType);
|
||||
|
||||
if (!node && normalizedType !== nodeType) {
|
||||
@@ -1865,43 +2010,6 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
||||
};
|
||||
}
|
||||
|
||||
private async getNodeForTask(task: string): Promise<any> {
|
||||
const template = TaskTemplates.getTaskTemplate(task);
|
||||
|
||||
if (!template) {
|
||||
// Try to find similar tasks
|
||||
const similar = TaskTemplates.searchTasks(task);
|
||||
throw new Error(
|
||||
`Unknown task: ${task}. ` +
|
||||
(similar.length > 0
|
||||
? `Did you mean: ${similar.slice(0, 3).join(', ')}?`
|
||||
: `Use 'list_tasks' to see available tasks.`)
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
task: template.task,
|
||||
description: template.description,
|
||||
nodeType: template.nodeType,
|
||||
configuration: template.configuration,
|
||||
userMustProvide: template.userMustProvide,
|
||||
optionalEnhancements: template.optionalEnhancements || [],
|
||||
notes: template.notes || [],
|
||||
example: {
|
||||
node: {
|
||||
type: template.nodeType,
|
||||
parameters: template.configuration
|
||||
},
|
||||
userInputsNeeded: template.userMustProvide.map(p => ({
|
||||
property: p.property,
|
||||
currentValue: this.getPropertyValue(template.configuration, p.property),
|
||||
description: p.description,
|
||||
example: p.example
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private getPropertyValue(config: any, path: string): any {
|
||||
const parts = path.split('.');
|
||||
let value = config;
|
||||
@@ -1972,17 +2080,17 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
||||
): Promise<any> {
|
||||
await this.ensureInitialized();
|
||||
if (!this.repository) throw new Error('Repository not initialized');
|
||||
|
||||
|
||||
// Get node info to access properties
|
||||
// First try with normalized type
|
||||
const normalizedType = normalizeNodeType(nodeType);
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
let node = this.repository.getNode(normalizedType);
|
||||
|
||||
|
||||
if (!node && normalizedType !== nodeType) {
|
||||
// Try original if normalization changed it
|
||||
node = this.repository.getNode(nodeType);
|
||||
}
|
||||
|
||||
|
||||
if (!node) {
|
||||
// Fallback to other alternatives for edge cases
|
||||
const alternatives = getNodeTypeAlternatives(normalizedType);
|
||||
@@ -2030,10 +2138,10 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
||||
private async getPropertyDependencies(nodeType: string, config?: Record<string, any>): Promise<any> {
|
||||
await this.ensureInitialized();
|
||||
if (!this.repository) throw new Error('Repository not initialized');
|
||||
|
||||
|
||||
// Get node info to access properties
|
||||
// First try with normalized type
|
||||
const normalizedType = normalizeNodeType(nodeType);
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
let node = this.repository.getNode(normalizedType);
|
||||
|
||||
if (!node && normalizedType !== nodeType) {
|
||||
@@ -2084,10 +2192,10 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
||||
private async getNodeAsToolInfo(nodeType: string): Promise<any> {
|
||||
await this.ensureInitialized();
|
||||
if (!this.repository) throw new Error('Repository not initialized');
|
||||
|
||||
|
||||
// Get node info
|
||||
// First try with normalized type
|
||||
const normalizedType = normalizeNodeType(nodeType);
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
let node = this.repository.getNode(normalizedType);
|
||||
|
||||
if (!node && normalizedType !== nodeType) {
|
||||
@@ -2307,10 +2415,10 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
||||
private async validateNodeMinimal(nodeType: string, config: Record<string, any>): Promise<any> {
|
||||
await this.ensureInitialized();
|
||||
if (!this.repository) throw new Error('Repository not initialized');
|
||||
|
||||
|
||||
// Get node info
|
||||
// First try with normalized type
|
||||
const normalizedType = normalizeNodeType(nodeType);
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
let node = this.repository.getNode(normalizedType);
|
||||
|
||||
if (!node && normalizedType !== nodeType) {
|
||||
|
||||
@@ -17,14 +17,13 @@ import {
|
||||
validateWorkflowConnectionsDoc,
|
||||
validateWorkflowExpressionsDoc
|
||||
} from './validation';
|
||||
import {
|
||||
listTasksDoc,
|
||||
getNodeForTaskDoc,
|
||||
listNodeTemplatesDoc,
|
||||
getTemplateDoc,
|
||||
import {
|
||||
listTasksDoc,
|
||||
listNodeTemplatesDoc,
|
||||
getTemplateDoc,
|
||||
searchTemplatesDoc,
|
||||
searchTemplatesByMetadataDoc,
|
||||
getTemplatesForTaskDoc
|
||||
searchTemplatesByMetadataDoc,
|
||||
getTemplatesForTaskDoc
|
||||
} from './templates';
|
||||
import {
|
||||
toolsDocumentationDoc,
|
||||
@@ -81,7 +80,6 @@ export const toolsDocumentation: Record<string, ToolDocumentation> = {
|
||||
|
||||
// Template tools
|
||||
list_tasks: listTasksDoc,
|
||||
get_node_for_task: getNodeForTaskDoc,
|
||||
list_node_templates: listNodeTemplatesDoc,
|
||||
get_template: getTemplateDoc,
|
||||
search_templates: searchTemplatesDoc,
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { ToolDocumentation } from '../types';
|
||||
|
||||
export const getNodeForTaskDoc: ToolDocumentation = {
|
||||
name: 'get_node_for_task',
|
||||
category: 'templates',
|
||||
essentials: {
|
||||
description: 'Get pre-configured node for tasks: post_json_request, receive_webhook, query_database, send_slack_message, etc. Use list_tasks for all.',
|
||||
keyParameters: ['task'],
|
||||
example: 'get_node_for_task({task: "post_json_request"})',
|
||||
performance: 'Instant',
|
||||
tips: [
|
||||
'Returns ready-to-use configuration',
|
||||
'See list_tasks for available tasks',
|
||||
'Includes credentials structure'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: 'Returns pre-configured node settings for common automation tasks. Each configuration includes the correct node type, essential parameters, and credential requirements. Perfect for quickly setting up standard automations.',
|
||||
parameters: {
|
||||
task: { type: 'string', required: true, description: 'Task name from list_tasks (e.g., "post_json_request", "send_email")' }
|
||||
},
|
||||
returns: 'Complete node configuration with type, displayName, parameters, credentials structure',
|
||||
examples: [
|
||||
'get_node_for_task({task: "post_json_request"}) - HTTP POST setup',
|
||||
'get_node_for_task({task: "receive_webhook"}) - Webhook receiver',
|
||||
'get_node_for_task({task: "send_slack_message"}) - Slack config'
|
||||
],
|
||||
useCases: [
|
||||
'Quick node configuration',
|
||||
'Learning proper node setup',
|
||||
'Standard automation patterns',
|
||||
'Credential structure reference'
|
||||
],
|
||||
performance: 'Instant - Pre-configured templates',
|
||||
bestPractices: [
|
||||
'Use list_tasks to discover options',
|
||||
'Customize returned config as needed',
|
||||
'Check credential requirements',
|
||||
'Validate with validate_node_operation'
|
||||
],
|
||||
pitfalls: [
|
||||
'Templates may need customization',
|
||||
'Credentials must be configured separately',
|
||||
'Not all tasks available for all nodes'
|
||||
],
|
||||
relatedTools: ['list_tasks', 'validate_node_operation', 'get_node_essentials']
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
export { getNodeForTaskDoc } from './get-node-for-task';
|
||||
export { listTasksDoc } from './list-tasks';
|
||||
export { listNodeTemplatesDoc } from './list-node-templates';
|
||||
export { getTemplateDoc } from './get-template';
|
||||
|
||||
@@ -10,9 +10,9 @@ export interface ToolDocumentation {
|
||||
};
|
||||
full: {
|
||||
description: string;
|
||||
parameters: Record<string, {
|
||||
type: string;
|
||||
description: string;
|
||||
parameters: Record<string, {
|
||||
type: string;
|
||||
description: string;
|
||||
required?: boolean;
|
||||
default?: any;
|
||||
examples?: string[];
|
||||
@@ -22,8 +22,10 @@ export interface ToolDocumentation {
|
||||
examples: string[];
|
||||
useCases: string[];
|
||||
performance: string;
|
||||
errorHandling?: string; // Optional: Documentation on error handling and debugging
|
||||
bestPractices: string[];
|
||||
pitfalls: string[];
|
||||
modeComparison?: string; // Optional: Comparison of different modes for tools with multiple modes
|
||||
relatedTools: string[];
|
||||
};
|
||||
}
|
||||
@@ -4,59 +4,280 @@ export const n8nGetExecutionDoc: ToolDocumentation = {
|
||||
name: 'n8n_get_execution',
|
||||
category: 'workflow_management',
|
||||
essentials: {
|
||||
description: 'Get details of a specific execution by ID, including status, timing, and error information.',
|
||||
keyParameters: ['id', 'includeData'],
|
||||
example: 'n8n_get_execution({id: "12345"})',
|
||||
performance: 'Fast lookup, data inclusion may increase response size significantly',
|
||||
description: 'Get execution details with smart filtering to avoid token limits. Use preview mode first to assess data size, then fetch appropriately.',
|
||||
keyParameters: ['id', 'mode', 'itemsLimit', 'nodeNames'],
|
||||
example: `
|
||||
// RECOMMENDED WORKFLOW:
|
||||
// 1. Preview first
|
||||
n8n_get_execution({id: "12345", mode: "preview"})
|
||||
// Returns: structure, counts, size estimate, recommendation
|
||||
|
||||
// 2. Based on recommendation, fetch data:
|
||||
n8n_get_execution({id: "12345", mode: "summary"}) // 2 items per node
|
||||
n8n_get_execution({id: "12345", mode: "filtered", itemsLimit: 5}) // 5 items
|
||||
n8n_get_execution({id: "12345", nodeNames: ["HTTP Request"]}) // Specific node
|
||||
`,
|
||||
performance: 'Preview: <50ms, Summary: <200ms, Full: depends on data size',
|
||||
tips: [
|
||||
'Use includeData:true to see full execution data and node outputs',
|
||||
'Execution IDs come from list_executions or webhook responses',
|
||||
'Check status field for success/error/waiting states'
|
||||
'ALWAYS use preview mode first for large datasets',
|
||||
'Preview shows structure + counts without consuming tokens for data',
|
||||
'Summary mode (2 items per node) is safe default',
|
||||
'Use nodeNames to focus on specific nodes only',
|
||||
'itemsLimit: 0 = structure only, -1 = unlimited',
|
||||
'Check recommendation.suggestedMode from preview'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: `Retrieves detailed information about a specific workflow execution. This tool is essential for monitoring workflow runs, debugging failures, and accessing execution results. Returns execution metadata by default, with optional full data inclusion for complete visibility into node inputs/outputs.`,
|
||||
description: `Retrieves and intelligently filters execution data to enable inspection without exceeding token limits. This tool provides multiple modes for different use cases, from quick previews to complete data retrieval.
|
||||
|
||||
**The Problem**: Workflows processing large datasets (50+ database records) generate execution data that exceeds token/response limits, making traditional full-data fetching impossible.
|
||||
|
||||
**The Solution**: Four retrieval modes with smart filtering:
|
||||
1. **Preview**: Structure + counts only (no actual data)
|
||||
2. **Summary**: 2 sample items per node (safe default)
|
||||
3. **Filtered**: Custom limits and node selection
|
||||
4. **Full**: Complete data (use with caution)
|
||||
|
||||
**Recommended Workflow**:
|
||||
1. Start with preview mode to assess size
|
||||
2. Use recommendation to choose appropriate mode
|
||||
3. Fetch filtered data as needed`,
|
||||
|
||||
parameters: {
|
||||
id: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The execution ID to retrieve. Obtained from list_executions or webhook trigger responses'
|
||||
},
|
||||
mode: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
description: `Retrieval mode (default: auto-detect from other params):
|
||||
- 'preview': Structure, counts, size estimates - NO actual data (fastest)
|
||||
- 'summary': Metadata + 2 sample items per node (safe default)
|
||||
- 'filtered': Custom filtering with itemsLimit/nodeNames
|
||||
- 'full': Complete execution data (use with caution)`
|
||||
},
|
||||
nodeNames: {
|
||||
type: 'array',
|
||||
required: false,
|
||||
description: 'Filter to specific nodes by name. Example: ["HTTP Request", "Filter"]. Useful when you only need to inspect specific nodes.'
|
||||
},
|
||||
itemsLimit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
description: `Items to return per node (default: 2):
|
||||
- 0: Structure only (see data shape without values)
|
||||
- 1-N: Return N items per node
|
||||
- -1: Unlimited (return all items)
|
||||
|
||||
Note: Structure-only mode (0) shows JSON schema without actual values.`
|
||||
},
|
||||
includeInputData: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'Include input data in addition to output data (default: false). Useful for debugging data transformations.'
|
||||
},
|
||||
includeData: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
description: 'Include full execution data with node inputs/outputs (default: false). Significantly increases response size'
|
||||
description: 'DEPRECATED: Legacy parameter. Use mode instead. If true, maps to mode="summary" for backward compatibility.'
|
||||
}
|
||||
},
|
||||
returns: `Execution object containing status, timing, error details, and optionally full execution data with all node inputs/outputs.`,
|
||||
examples: [
|
||||
'n8n_get_execution({id: "12345"}) - Get execution summary only',
|
||||
'n8n_get_execution({id: "12345", includeData: true}) - Get full execution with all data',
|
||||
'n8n_get_execution({id: "67890"}) - Check status of a running execution',
|
||||
'n8n_get_execution({id: "failed-123", includeData: true}) - Debug failed execution with error details'
|
||||
],
|
||||
useCases: [
|
||||
'Monitor status of triggered workflow executions',
|
||||
'Debug failed workflows by examining error messages',
|
||||
'Access execution results and node output data',
|
||||
'Track execution duration and performance metrics',
|
||||
'Verify successful completion of critical workflows'
|
||||
],
|
||||
performance: `Metadata retrieval is fast (< 100ms). Including full data (includeData: true) can significantly increase response time and size, especially for workflows processing large datasets. Use data inclusion judiciously.`,
|
||||
bestPractices: [
|
||||
'Start with includeData:false to check status first',
|
||||
'Only include data when you need to see node outputs',
|
||||
'Store execution IDs from trigger responses for tracking',
|
||||
'Check status field to determine if execution completed',
|
||||
'Use error field to diagnose execution failures'
|
||||
],
|
||||
pitfalls: [
|
||||
'Large executions with includeData:true can timeout or exceed limits',
|
||||
'Execution data is retained based on n8n settings - old executions may be purged',
|
||||
'Waiting status indicates execution is still running',
|
||||
'Error executions may have partial data from successful nodes',
|
||||
'Execution IDs are unique per n8n instance'
|
||||
],
|
||||
relatedTools: ['n8n_list_executions', 'n8n_trigger_webhook_workflow', 'n8n_delete_execution', 'n8n_get_workflow']
|
||||
|
||||
returns: `**Preview Mode Response**:
|
||||
{
|
||||
mode: 'preview',
|
||||
preview: {
|
||||
totalNodes: number,
|
||||
executedNodes: number,
|
||||
estimatedSizeKB: number,
|
||||
nodes: {
|
||||
[nodeName]: {
|
||||
status: 'success' | 'error',
|
||||
itemCounts: { input: number, output: number },
|
||||
dataStructure: {...}, // JSON schema
|
||||
estimatedSizeKB: number
|
||||
}
|
||||
}
|
||||
},
|
||||
recommendation: {
|
||||
canFetchFull: boolean,
|
||||
suggestedMode: 'preview'|'summary'|'filtered'|'full',
|
||||
suggestedItemsLimit?: number,
|
||||
reason: string
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
**Summary/Filtered/Full Mode Response**:
|
||||
{
|
||||
mode: 'summary' | 'filtered' | 'full',
|
||||
summary: {
|
||||
totalNodes: number,
|
||||
executedNodes: number,
|
||||
totalItems: number,
|
||||
hasMoreData: boolean // true if truncated
|
||||
},
|
||||
nodes: {
|
||||
[nodeName]: {
|
||||
executionTime: number,
|
||||
itemsInput: number,
|
||||
itemsOutput: number,
|
||||
status: 'success' | 'error',
|
||||
error?: string,
|
||||
data: {
|
||||
output: [...], // Actual data items
|
||||
metadata: {
|
||||
totalItems: number,
|
||||
itemsShown: number,
|
||||
truncated: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
|
||||
examples: [
|
||||
`// Example 1: Preview workflow (RECOMMENDED FIRST STEP)
|
||||
n8n_get_execution({id: "exec_123", mode: "preview"})
|
||||
// Returns structure, counts, size, recommendation
|
||||
// Use this to decide how to fetch data`,
|
||||
|
||||
`// Example 2: Follow recommendation
|
||||
const preview = n8n_get_execution({id: "exec_123", mode: "preview"});
|
||||
if (preview.recommendation.canFetchFull) {
|
||||
n8n_get_execution({id: "exec_123", mode: "full"});
|
||||
} else {
|
||||
n8n_get_execution({
|
||||
id: "exec_123",
|
||||
mode: "filtered",
|
||||
itemsLimit: preview.recommendation.suggestedItemsLimit
|
||||
});
|
||||
}`,
|
||||
|
||||
`// Example 3: Summary mode (safe default for unknown datasets)
|
||||
n8n_get_execution({id: "exec_123", mode: "summary"})
|
||||
// Gets 2 items per node - safe for most cases`,
|
||||
|
||||
`// Example 4: Filter to specific node
|
||||
n8n_get_execution({
|
||||
id: "exec_123",
|
||||
mode: "filtered",
|
||||
nodeNames: ["HTTP Request"],
|
||||
itemsLimit: 5
|
||||
})
|
||||
// Gets only HTTP Request node, 5 items`,
|
||||
|
||||
`// Example 5: Structure only (see data shape)
|
||||
n8n_get_execution({
|
||||
id: "exec_123",
|
||||
mode: "filtered",
|
||||
itemsLimit: 0
|
||||
})
|
||||
// Returns JSON schema without actual values`,
|
||||
|
||||
`// Example 6: Debug with input data
|
||||
n8n_get_execution({
|
||||
id: "exec_123",
|
||||
mode: "filtered",
|
||||
nodeNames: ["Transform"],
|
||||
itemsLimit: 2,
|
||||
includeInputData: true
|
||||
})
|
||||
// See both input and output for debugging`,
|
||||
|
||||
`// Example 7: Backward compatibility (legacy)
|
||||
n8n_get_execution({id: "exec_123"}) // Minimal data
|
||||
n8n_get_execution({id: "exec_123", includeData: true}) // Maps to summary mode`
|
||||
],
|
||||
|
||||
useCases: [
|
||||
'Monitor status of triggered workflows',
|
||||
'Debug failed workflows by examining error messages and partial data',
|
||||
'Inspect large datasets without exceeding token limits',
|
||||
'Validate data transformations between nodes',
|
||||
'Understand execution flow and timing',
|
||||
'Track workflow performance metrics',
|
||||
'Verify successful completion before proceeding',
|
||||
'Extract specific data from execution results'
|
||||
],
|
||||
|
||||
performance: `**Response Times** (approximate):
|
||||
- Preview mode: <50ms (no data, just structure)
|
||||
- Summary mode: <200ms (2 items per node)
|
||||
- Filtered mode: 50-500ms (depends on filters)
|
||||
- Full mode: 200ms-5s (depends on data size)
|
||||
|
||||
**Token Consumption**:
|
||||
- Preview: ~500 tokens (no data values)
|
||||
- Summary (2 items): ~2-5K tokens
|
||||
- Filtered (5 items): ~5-15K tokens
|
||||
- Full (50+ items): 50K+ tokens (may exceed limits)
|
||||
|
||||
**Optimization Tips**:
|
||||
- Use preview for all large datasets
|
||||
- Use nodeNames to focus on relevant nodes only
|
||||
- Start with small itemsLimit and increase if needed
|
||||
- Use itemsLimit: 0 to see structure without data`,
|
||||
|
||||
bestPractices: [
|
||||
'ALWAYS use preview mode first for unknown datasets',
|
||||
'Trust the recommendation.suggestedMode from preview',
|
||||
'Use nodeNames to filter to relevant nodes only',
|
||||
'Start with summary mode if preview indicates moderate size',
|
||||
'Use itemsLimit: 0 to understand data structure',
|
||||
'Check hasMoreData to know if results are truncated',
|
||||
'Store execution IDs from triggers for later inspection',
|
||||
'Use mode="filtered" with custom limits for large datasets',
|
||||
'Include input data only when debugging transformations',
|
||||
'Monitor summary.totalItems to understand dataset size'
|
||||
],
|
||||
|
||||
pitfalls: [
|
||||
'DON\'T fetch full mode without previewing first - may timeout',
|
||||
'DON\'T assume all data fits - always check hasMoreData',
|
||||
'DON\'T ignore the recommendation from preview mode',
|
||||
'Execution data is retained based on n8n settings - old executions may be purged',
|
||||
'Binary data (files, images) is not fully included - only metadata',
|
||||
'Status "waiting" indicates execution is still running',
|
||||
'Error executions may have partial data from successful nodes',
|
||||
'Very large individual items (>1MB) may be truncated',
|
||||
'Preview mode estimates may be off by 10-20% for complex structures',
|
||||
'Node names are case-sensitive in nodeNames filter'
|
||||
],
|
||||
|
||||
modeComparison: `**When to use each mode**:
|
||||
|
||||
**Preview**:
|
||||
- ALWAYS use first for unknown datasets
|
||||
- When you need to know if data is safe to fetch
|
||||
- To see data structure without consuming tokens
|
||||
- To get size estimates and recommendations
|
||||
|
||||
**Summary** (default):
|
||||
- Safe default for most cases
|
||||
- When you need representative samples
|
||||
- When preview recommends it
|
||||
- For quick data inspection
|
||||
|
||||
**Filtered**:
|
||||
- When you need specific nodes only
|
||||
- When you need more than 2 items but not all
|
||||
- When preview recommends it with itemsLimit
|
||||
- For targeted data extraction
|
||||
|
||||
**Full**:
|
||||
- ONLY when preview says canFetchFull: true
|
||||
- For small executions (< 20 items total)
|
||||
- When you genuinely need all data
|
||||
- When you're certain data fits in token limit`,
|
||||
|
||||
relatedTools: [
|
||||
'n8n_list_executions - Find execution IDs',
|
||||
'n8n_trigger_webhook_workflow - Trigger and get execution ID',
|
||||
'n8n_delete_execution - Clean up old executions',
|
||||
'n8n_get_workflow - Get workflow structure',
|
||||
'validate_workflow - Validate before executing'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -59,19 +59,59 @@ export const n8nTriggerWebhookWorkflowDoc: ToolDocumentation = {
|
||||
'Implement event-driven architectures with n8n'
|
||||
],
|
||||
performance: `Performance varies based on workflow complexity and waitForResponse setting. Synchronous calls (waitForResponse: true) block until workflow completes. For long-running workflows, use async mode (waitForResponse: false) and monitor execution separately.`,
|
||||
errorHandling: `**Enhanced Error Messages with Execution Guidance**
|
||||
|
||||
When a webhook trigger fails, the error response now includes specific guidance to help debug the issue:
|
||||
|
||||
**Error with Execution ID** (workflow started but failed):
|
||||
- Format: "Workflow {workflowId} execution {executionId} failed. Use n8n_get_execution({id: '{executionId}', mode: 'preview'}) to investigate the error."
|
||||
- Response includes: executionId and workflowId fields for direct access
|
||||
- Recommended action: Use n8n_get_execution with mode='preview' for fast, efficient error inspection
|
||||
|
||||
**Error without Execution ID** (workflow didn't start):
|
||||
- Format: "Workflow failed to execute. Use n8n_list_executions to find recent executions, then n8n_get_execution with mode='preview' to investigate."
|
||||
- Recommended action: Check recent executions with n8n_list_executions
|
||||
|
||||
**Why mode='preview'?**
|
||||
- Fast: <50ms response time
|
||||
- Efficient: ~500 tokens (vs 50K+ for full mode)
|
||||
- Safe: No timeout or token limit risks
|
||||
- Informative: Shows structure, counts, and error details
|
||||
- Provides recommendations for fetching more data if needed
|
||||
|
||||
**Example Error Responses**:
|
||||
\`\`\`json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Workflow wf_123 execution exec_456 failed. Use n8n_get_execution({id: 'exec_456', mode: 'preview'}) to investigate the error.",
|
||||
"executionId": "exec_456",
|
||||
"workflowId": "wf_123",
|
||||
"code": "SERVER_ERROR"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Investigation Workflow**:
|
||||
1. Trigger returns error with execution ID
|
||||
2. Call n8n_get_execution({id: executionId, mode: 'preview'}) to see structure and error
|
||||
3. Based on preview recommendation, fetch more data if needed
|
||||
4. Fix issues in workflow and retry`,
|
||||
bestPractices: [
|
||||
'Always verify workflow is active before attempting webhook triggers',
|
||||
'Match HTTP method exactly with webhook node configuration',
|
||||
'Use async mode (waitForResponse: false) for long-running workflows',
|
||||
'Include authentication headers when webhook requires them',
|
||||
'Test webhook URL manually first to ensure it works'
|
||||
'Test webhook URL manually first to ensure it works',
|
||||
'When errors occur, use n8n_get_execution with mode="preview" first for efficient debugging',
|
||||
'Store execution IDs from error responses for later investigation'
|
||||
],
|
||||
pitfalls: [
|
||||
'Workflow must be ACTIVE - inactive workflows cannot be triggered',
|
||||
'HTTP method mismatch returns 404 even if URL is correct',
|
||||
'Webhook node must be the trigger node in the workflow',
|
||||
'Timeout errors occur with long workflows in sync mode',
|
||||
'Data format must match webhook node expectations'
|
||||
'Data format must match webhook node expectations',
|
||||
'Error messages always include n8n_get_execution guidance - follow the suggested steps for efficient debugging',
|
||||
'Execution IDs in error responses are crucial for debugging - always check for and use them'
|
||||
],
|
||||
relatedTools: ['n8n_get_execution', 'n8n_list_executions', 'n8n_get_workflow', 'n8n_create_workflow']
|
||||
}
|
||||
|
||||
@@ -344,17 +344,41 @@ export const n8nManagementTools: ToolDefinition[] = [
|
||||
},
|
||||
{
|
||||
name: 'n8n_get_execution',
|
||||
description: `Get details of a specific execution by ID.`,
|
||||
description: `Get execution details with smart filtering. RECOMMENDED: Use mode='preview' first to assess data size.
|
||||
Examples:
|
||||
- {id, mode:'preview'} - Structure & counts (fast, no data)
|
||||
- {id, mode:'summary'} - 2 samples per node (default)
|
||||
- {id, mode:'filtered', itemsLimit:5} - 5 items per node
|
||||
- {id, nodeNames:['HTTP Request']} - Specific node only
|
||||
- {id, mode:'full'} - Complete data (use with caution)`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Execution ID'
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Execution ID'
|
||||
},
|
||||
includeData: {
|
||||
type: 'boolean',
|
||||
description: 'Include full execution data (default: false)'
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['preview', 'summary', 'filtered', 'full'],
|
||||
description: 'Data retrieval mode: preview=structure only, summary=2 items, filtered=custom, full=all data'
|
||||
},
|
||||
nodeNames: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Filter to specific nodes by name (for filtered mode)'
|
||||
},
|
||||
itemsLimit: {
|
||||
type: 'number',
|
||||
description: 'Items per node: 0=structure only, 2=default, -1=unlimited (for filtered mode)'
|
||||
},
|
||||
includeInputData: {
|
||||
type: 'boolean',
|
||||
description: 'Include input data in addition to output (default: false)'
|
||||
},
|
||||
includeData: {
|
||||
type: 'boolean',
|
||||
description: 'Legacy: Include execution data. Maps to mode=summary if true (deprecated, use mode instead)'
|
||||
}
|
||||
},
|
||||
required: ['id']
|
||||
|
||||
@@ -73,7 +73,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
},
|
||||
{
|
||||
name: 'search_nodes',
|
||||
description: `Search n8n nodes by keyword. Pass query as string. Example: query="webhook" or query="database". Returns max 20 results.`,
|
||||
description: `Search n8n nodes by keyword with optional real-world examples. Pass query as string. Example: query="webhook" or query="database". Returns max 20 results. Use includeExamples=true to get top 2 template configs per node.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -92,6 +92,11 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
description: 'OR=any word, AND=all words, FUZZY=typo-tolerant',
|
||||
default: 'OR',
|
||||
},
|
||||
includeExamples: {
|
||||
type: 'boolean',
|
||||
description: 'Include top 2 real-world configuration examples from popular templates (default: false)',
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
@@ -128,7 +133,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
},
|
||||
{
|
||||
name: 'get_node_essentials',
|
||||
description: `Get node essential info. Pass nodeType as string with prefix. Example: nodeType="nodes-base.slack"`,
|
||||
description: `Get node essential info with optional real-world examples from templates. Pass nodeType as string with prefix. Example: nodeType="nodes-base.slack". Use includeExamples=true to get top 3 template configs.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -136,6 +141,11 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
type: 'string',
|
||||
description: 'Full type: "nodes-base.httpRequest"',
|
||||
},
|
||||
includeExamples: {
|
||||
type: 'boolean',
|
||||
description: 'Include top 3 real-world configuration examples from popular templates (default: false)',
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
required: ['nodeType'],
|
||||
},
|
||||
@@ -163,20 +173,6 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
required: ['nodeType', 'query'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_node_for_task',
|
||||
description: `Get pre-configured node for tasks: post_json_request, receive_webhook, query_database, send_slack_message, etc. Use list_tasks for all.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
task: {
|
||||
type: 'string',
|
||||
description: 'Task name. See list_tasks for options.',
|
||||
},
|
||||
},
|
||||
required: ['task'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'list_tasks',
|
||||
description: `List task templates by category: HTTP/API, Webhooks, Database, AI, Data Processing, Communication.`,
|
||||
|
||||
@@ -10,21 +10,240 @@ import type { MetadataRequest } from '../templates/metadata-generator';
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
async function fetchTemplates(mode: 'rebuild' | 'update' = 'rebuild', generateMetadata: boolean = false, metadataOnly: boolean = false) {
|
||||
/**
|
||||
* Extract node configurations from a template workflow
|
||||
*/
|
||||
function extractNodeConfigs(
|
||||
templateId: number,
|
||||
templateName: string,
|
||||
templateViews: number,
|
||||
workflowCompressed: string,
|
||||
metadata: any
|
||||
): Array<{
|
||||
node_type: string;
|
||||
template_id: number;
|
||||
template_name: string;
|
||||
template_views: number;
|
||||
node_name: string;
|
||||
parameters_json: string;
|
||||
credentials_json: string | null;
|
||||
has_credentials: number;
|
||||
has_expressions: number;
|
||||
complexity: string;
|
||||
use_cases: string;
|
||||
}> {
|
||||
try {
|
||||
// Decompress workflow
|
||||
const decompressed = zlib.gunzipSync(Buffer.from(workflowCompressed, 'base64'));
|
||||
const workflow = JSON.parse(decompressed.toString('utf-8'));
|
||||
|
||||
const configs: any[] = [];
|
||||
|
||||
for (const node of workflow.nodes || []) {
|
||||
// Skip UI-only nodes (sticky notes, etc.)
|
||||
if (node.type.includes('stickyNote') || !node.parameters) {
|
||||
continue;
|
||||
}
|
||||
|
||||
configs.push({
|
||||
node_type: node.type,
|
||||
template_id: templateId,
|
||||
template_name: templateName,
|
||||
template_views: templateViews,
|
||||
node_name: node.name,
|
||||
parameters_json: JSON.stringify(node.parameters),
|
||||
credentials_json: node.credentials ? JSON.stringify(node.credentials) : null,
|
||||
has_credentials: node.credentials ? 1 : 0,
|
||||
has_expressions: detectExpressions(node.parameters) ? 1 : 0,
|
||||
complexity: metadata?.complexity || 'medium',
|
||||
use_cases: JSON.stringify(metadata?.use_cases || [])
|
||||
});
|
||||
}
|
||||
|
||||
return configs;
|
||||
} catch (error) {
|
||||
console.error(`Error extracting configs from template ${templateId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect n8n expressions in parameters
|
||||
*/
|
||||
function detectExpressions(params: any): boolean {
|
||||
if (!params) return false;
|
||||
const json = JSON.stringify(params);
|
||||
return json.includes('={{') || json.includes('$json') || json.includes('$node');
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert extracted configs into database and rank them
|
||||
*/
|
||||
function insertAndRankConfigs(db: any, configs: any[]) {
|
||||
if (configs.length === 0) {
|
||||
console.log('No configs to insert');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear old configs for these templates
|
||||
const templateIds = [...new Set(configs.map(c => c.template_id))];
|
||||
const placeholders = templateIds.map(() => '?').join(',');
|
||||
db.prepare(`DELETE FROM template_node_configs WHERE template_id IN (${placeholders})`).run(...templateIds);
|
||||
|
||||
// Insert new configs
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, credentials_json,
|
||||
has_credentials, has_expressions, complexity, use_cases
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const config of configs) {
|
||||
insertStmt.run(
|
||||
config.node_type,
|
||||
config.template_id,
|
||||
config.template_name,
|
||||
config.template_views,
|
||||
config.node_name,
|
||||
config.parameters_json,
|
||||
config.credentials_json,
|
||||
config.has_credentials,
|
||||
config.has_expressions,
|
||||
config.complexity,
|
||||
config.use_cases
|
||||
);
|
||||
}
|
||||
|
||||
// Rank configs per node_type by template popularity
|
||||
db.exec(`
|
||||
UPDATE template_node_configs
|
||||
SET rank = (
|
||||
SELECT COUNT(*) + 1
|
||||
FROM template_node_configs AS t2
|
||||
WHERE t2.node_type = template_node_configs.node_type
|
||||
AND t2.template_views > template_node_configs.template_views
|
||||
)
|
||||
`);
|
||||
|
||||
// Keep only top 10 per node_type
|
||||
db.exec(`
|
||||
DELETE FROM template_node_configs
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM template_node_configs
|
||||
WHERE rank <= 10
|
||||
ORDER BY node_type, rank
|
||||
)
|
||||
`);
|
||||
|
||||
console.log(`✅ Extracted and ranked ${configs.length} node configurations`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract node configurations from existing templates
|
||||
*/
|
||||
async function extractTemplateConfigs(db: any, service: TemplateService) {
|
||||
console.log('📦 Extracting node configurations from templates...');
|
||||
const repository = (service as any).repository;
|
||||
const allTemplates = repository.getAllTemplates();
|
||||
|
||||
const allConfigs: any[] = [];
|
||||
let configsExtracted = 0;
|
||||
|
||||
for (const template of allTemplates) {
|
||||
if (template.workflow_json_compressed) {
|
||||
const metadata = template.metadata_json ? JSON.parse(template.metadata_json) : null;
|
||||
const configs = extractNodeConfigs(
|
||||
template.id,
|
||||
template.name,
|
||||
template.views,
|
||||
template.workflow_json_compressed,
|
||||
metadata
|
||||
);
|
||||
allConfigs.push(...configs);
|
||||
configsExtracted += configs.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (allConfigs.length > 0) {
|
||||
insertAndRankConfigs(db, allConfigs);
|
||||
|
||||
// Show stats
|
||||
const configStats = db.prepare(`
|
||||
SELECT
|
||||
COUNT(DISTINCT node_type) as node_types,
|
||||
COUNT(*) as total_configs,
|
||||
AVG(rank) as avg_rank
|
||||
FROM template_node_configs
|
||||
`).get() as any;
|
||||
|
||||
console.log(`📊 Node config stats:`);
|
||||
console.log(` - Unique node types: ${configStats.node_types}`);
|
||||
console.log(` - Total configs stored: ${configStats.total_configs}`);
|
||||
console.log(` - Average rank: ${configStats.avg_rank?.toFixed(1) || 'N/A'}`);
|
||||
} else {
|
||||
console.log('⚠️ No node configurations extracted');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTemplates(
|
||||
mode: 'rebuild' | 'update' = 'rebuild',
|
||||
generateMetadata: boolean = false,
|
||||
metadataOnly: boolean = false,
|
||||
extractOnly: boolean = false
|
||||
) {
|
||||
// If extract-only mode, skip template fetching and only extract configs
|
||||
if (extractOnly) {
|
||||
console.log('📦 Extract-only mode: Extracting node configurations from existing templates...\n');
|
||||
|
||||
const db = await createDatabaseAdapter('./data/nodes.db');
|
||||
|
||||
// Ensure template_node_configs table exists
|
||||
try {
|
||||
const tableExists = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='template_node_configs'
|
||||
`).get();
|
||||
|
||||
if (!tableExists) {
|
||||
console.log('📋 Creating template_node_configs table...');
|
||||
const migrationPath = path.join(__dirname, '../../src/database/migrations/add-template-node-configs.sql');
|
||||
const migration = fs.readFileSync(migrationPath, 'utf8');
|
||||
db.exec(migration);
|
||||
console.log('✅ Table created successfully\n');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error checking/creating template_node_configs table:', error);
|
||||
if ('close' in db && typeof db.close === 'function') {
|
||||
db.close();
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const service = new TemplateService(db);
|
||||
|
||||
await extractTemplateConfigs(db, service);
|
||||
|
||||
if ('close' in db && typeof db.close === 'function') {
|
||||
db.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If metadata-only mode, skip template fetching entirely
|
||||
if (metadataOnly) {
|
||||
console.log('🤖 Metadata-only mode: Generating metadata for existing templates...\n');
|
||||
|
||||
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
console.error('❌ OPENAI_API_KEY not set in environment');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
const db = await createDatabaseAdapter('./data/nodes.db');
|
||||
const service = new TemplateService(db);
|
||||
|
||||
|
||||
await generateTemplateMetadata(db, service);
|
||||
|
||||
|
||||
if ('close' in db && typeof db.close === 'function') {
|
||||
db.close();
|
||||
}
|
||||
@@ -125,7 +344,11 @@ async function fetchTemplates(mode: 'rebuild' | 'update' = 'rebuild', generateMe
|
||||
stats.topUsedNodes.forEach((node: any, index: number) => {
|
||||
console.log(` ${index + 1}. ${node.node} (${node.count} templates)`);
|
||||
});
|
||||
|
||||
|
||||
// Extract node configurations from templates
|
||||
console.log('');
|
||||
await extractTemplateConfigs(db, service);
|
||||
|
||||
// Generate metadata if requested
|
||||
if (generateMetadata && process.env.OPENAI_API_KEY) {
|
||||
console.log('\n🤖 Generating metadata for templates...');
|
||||
@@ -133,7 +356,7 @@ async function fetchTemplates(mode: 'rebuild' | 'update' = 'rebuild', generateMe
|
||||
} else if (generateMetadata && !process.env.OPENAI_API_KEY) {
|
||||
console.log('\n⚠️ Metadata generation requested but OPENAI_API_KEY not set');
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Error fetching templates:', error);
|
||||
process.exit(1);
|
||||
@@ -237,39 +460,45 @@ async function generateTemplateMetadata(db: any, service: TemplateService) {
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
function parseArgs(): { mode: 'rebuild' | 'update', generateMetadata: boolean, metadataOnly: boolean } {
|
||||
function parseArgs(): { mode: 'rebuild' | 'update', generateMetadata: boolean, metadataOnly: boolean, extractOnly: boolean } {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
|
||||
let mode: 'rebuild' | 'update' = 'rebuild';
|
||||
let generateMetadata = false;
|
||||
let metadataOnly = false;
|
||||
|
||||
let extractOnly = false;
|
||||
|
||||
// Check for --mode flag
|
||||
const modeIndex = args.findIndex(arg => arg.startsWith('--mode'));
|
||||
if (modeIndex !== -1) {
|
||||
const modeArg = args[modeIndex];
|
||||
const modeValue = modeArg.includes('=') ? modeArg.split('=')[1] : args[modeIndex + 1];
|
||||
|
||||
|
||||
if (modeValue === 'update') {
|
||||
mode = 'update';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check for --update flag as shorthand
|
||||
if (args.includes('--update')) {
|
||||
mode = 'update';
|
||||
}
|
||||
|
||||
|
||||
// Check for --generate-metadata flag
|
||||
if (args.includes('--generate-metadata') || args.includes('--metadata')) {
|
||||
generateMetadata = true;
|
||||
}
|
||||
|
||||
|
||||
// Check for --metadata-only flag
|
||||
if (args.includes('--metadata-only')) {
|
||||
metadataOnly = true;
|
||||
}
|
||||
|
||||
|
||||
// Check for --extract-only flag
|
||||
if (args.includes('--extract-only') || args.includes('--extract')) {
|
||||
extractOnly = true;
|
||||
}
|
||||
|
||||
// Show help if requested
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log('Usage: npm run fetch:templates [options]\n');
|
||||
@@ -279,17 +508,19 @@ function parseArgs(): { mode: 'rebuild' | 'update', generateMetadata: boolean, m
|
||||
console.log(' --generate-metadata Generate AI metadata after fetching templates');
|
||||
console.log(' --metadata Shorthand for --generate-metadata');
|
||||
console.log(' --metadata-only Only generate metadata, skip template fetching');
|
||||
console.log(' --extract-only Only extract node configs, skip template fetching');
|
||||
console.log(' --extract Shorthand for --extract-only');
|
||||
console.log(' --help, -h Show this help message');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
return { mode, generateMetadata, metadataOnly };
|
||||
|
||||
return { mode, generateMetadata, metadataOnly, extractOnly };
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
const { mode, generateMetadata, metadataOnly } = parseArgs();
|
||||
fetchTemplates(mode, generateMetadata, metadataOnly).catch(console.error);
|
||||
const { mode, generateMetadata, metadataOnly, extractOnly } = parseArgs();
|
||||
fetchTemplates(mode, generateMetadata, metadataOnly, extractOnly).catch(console.error);
|
||||
}
|
||||
|
||||
export { fetchTemplates };
|
||||
302
src/scripts/test-execution-filtering.ts
Normal file
302
src/scripts/test-execution-filtering.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Manual testing script for execution filtering feature
|
||||
*
|
||||
* This script demonstrates all modes of the n8n_get_execution tool
|
||||
* with various filtering options.
|
||||
*
|
||||
* Usage: npx tsx src/scripts/test-execution-filtering.ts
|
||||
*/
|
||||
|
||||
import {
|
||||
generatePreview,
|
||||
filterExecutionData,
|
||||
processExecution,
|
||||
} from '../services/execution-processor';
|
||||
import { ExecutionFilterOptions, Execution, ExecutionStatus } from '../types/n8n-api';
|
||||
|
||||
console.log('='.repeat(80));
|
||||
console.log('Execution Filtering Feature - Manual Test Suite');
|
||||
console.log('='.repeat(80));
|
||||
console.log('');
|
||||
|
||||
/**
|
||||
* Mock execution factory (simplified version for testing)
|
||||
*/
|
||||
function createTestExecution(itemCount: number): Execution {
|
||||
const items = Array.from({ length: itemCount }, (_, i) => ({
|
||||
json: {
|
||||
id: i + 1,
|
||||
name: `Item ${i + 1}`,
|
||||
email: `user${i}@example.com`,
|
||||
value: Math.random() * 1000,
|
||||
metadata: {
|
||||
createdAt: new Date().toISOString(),
|
||||
tags: ['tag1', 'tag2'],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
return {
|
||||
id: `test-exec-${Date.now()}`,
|
||||
workflowId: 'workflow-test',
|
||||
status: ExecutionStatus.SUCCESS,
|
||||
mode: 'manual',
|
||||
finished: true,
|
||||
startedAt: '2024-01-01T10:00:00.000Z',
|
||||
stoppedAt: '2024-01-01T10:00:05.000Z',
|
||||
data: {
|
||||
resultData: {
|
||||
runData: {
|
||||
'HTTP Request': [
|
||||
{
|
||||
startTime: Date.now(),
|
||||
executionTime: 234,
|
||||
data: {
|
||||
main: [items],
|
||||
},
|
||||
},
|
||||
],
|
||||
'Filter': [
|
||||
{
|
||||
startTime: Date.now(),
|
||||
executionTime: 45,
|
||||
data: {
|
||||
main: [items.slice(0, Math.floor(itemCount / 2))],
|
||||
},
|
||||
},
|
||||
],
|
||||
'Set': [
|
||||
{
|
||||
startTime: Date.now(),
|
||||
executionTime: 12,
|
||||
data: {
|
||||
main: [items.slice(0, 5)],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Test 1: Preview Mode
|
||||
*/
|
||||
console.log('📊 TEST 1: Preview Mode (No Data, Just Structure)');
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
const execution1 = createTestExecution(50);
|
||||
const { preview, recommendation } = generatePreview(execution1);
|
||||
|
||||
console.log('Preview:', JSON.stringify(preview, null, 2));
|
||||
console.log('\nRecommendation:', JSON.stringify(recommendation, null, 2));
|
||||
console.log('\n✅ Preview mode shows structure without consuming tokens for data\n');
|
||||
|
||||
/**
|
||||
* Test 2: Summary Mode (Default)
|
||||
*/
|
||||
console.log('📝 TEST 2: Summary Mode (2 items per node)');
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
const execution2 = createTestExecution(50);
|
||||
const summaryResult = filterExecutionData(execution2, { mode: 'summary' });
|
||||
|
||||
console.log('Summary Mode Result:');
|
||||
console.log('- Mode:', summaryResult.mode);
|
||||
console.log('- Summary:', JSON.stringify(summaryResult.summary, null, 2));
|
||||
console.log('- HTTP Request items shown:', summaryResult.nodes?.['HTTP Request']?.data?.metadata.itemsShown);
|
||||
console.log('- HTTP Request truncated:', summaryResult.nodes?.['HTTP Request']?.data?.metadata.truncated);
|
||||
console.log('\n✅ Summary mode returns 2 items per node (safe default)\n');
|
||||
|
||||
/**
|
||||
* Test 3: Filtered Mode with Custom Limit
|
||||
*/
|
||||
console.log('🎯 TEST 3: Filtered Mode (Custom itemsLimit: 5)');
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
const execution3 = createTestExecution(100);
|
||||
const filteredResult = filterExecutionData(execution3, {
|
||||
mode: 'filtered',
|
||||
itemsLimit: 5,
|
||||
});
|
||||
|
||||
console.log('Filtered Mode Result:');
|
||||
console.log('- Items shown per node:', filteredResult.nodes?.['HTTP Request']?.data?.metadata.itemsShown);
|
||||
console.log('- Total items available:', filteredResult.nodes?.['HTTP Request']?.data?.metadata.totalItems);
|
||||
console.log('- More data available:', filteredResult.summary?.hasMoreData);
|
||||
console.log('\n✅ Filtered mode allows custom item limits\n');
|
||||
|
||||
/**
|
||||
* Test 4: Node Name Filtering
|
||||
*/
|
||||
console.log('🔍 TEST 4: Filter to Specific Nodes');
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
const execution4 = createTestExecution(30);
|
||||
const nodeFilterResult = filterExecutionData(execution4, {
|
||||
mode: 'filtered',
|
||||
nodeNames: ['HTTP Request'],
|
||||
itemsLimit: 3,
|
||||
});
|
||||
|
||||
console.log('Node Filter Result:');
|
||||
console.log('- Nodes in result:', Object.keys(nodeFilterResult.nodes || {}));
|
||||
console.log('- Expected: ["HTTP Request"]');
|
||||
console.log('- Executed nodes:', nodeFilterResult.summary?.executedNodes);
|
||||
console.log('- Total nodes:', nodeFilterResult.summary?.totalNodes);
|
||||
console.log('\n✅ Can filter to specific nodes only\n');
|
||||
|
||||
/**
|
||||
* Test 5: Structure-Only Mode (itemsLimit: 0)
|
||||
*/
|
||||
console.log('🏗️ TEST 5: Structure-Only Mode (itemsLimit: 0)');
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
const execution5 = createTestExecution(100);
|
||||
const structureResult = filterExecutionData(execution5, {
|
||||
mode: 'filtered',
|
||||
itemsLimit: 0,
|
||||
});
|
||||
|
||||
console.log('Structure-Only Result:');
|
||||
console.log('- Items shown:', structureResult.nodes?.['HTTP Request']?.data?.metadata.itemsShown);
|
||||
console.log('- First item (structure):', JSON.stringify(
|
||||
structureResult.nodes?.['HTTP Request']?.data?.output?.[0]?.[0],
|
||||
null,
|
||||
2
|
||||
));
|
||||
console.log('\n✅ Structure-only mode shows data shape without values\n');
|
||||
|
||||
/**
|
||||
* Test 6: Full Mode
|
||||
*/
|
||||
console.log('💾 TEST 6: Full Mode (All Data)');
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
const execution6 = createTestExecution(5); // Small dataset
|
||||
const fullResult = filterExecutionData(execution6, { mode: 'full' });
|
||||
|
||||
console.log('Full Mode Result:');
|
||||
console.log('- Items shown:', fullResult.nodes?.['HTTP Request']?.data?.metadata.itemsShown);
|
||||
console.log('- Total items:', fullResult.nodes?.['HTTP Request']?.data?.metadata.totalItems);
|
||||
console.log('- Truncated:', fullResult.nodes?.['HTTP Request']?.data?.metadata.truncated);
|
||||
console.log('\n✅ Full mode returns all data (use with caution)\n');
|
||||
|
||||
/**
|
||||
* Test 7: Backward Compatibility
|
||||
*/
|
||||
console.log('🔄 TEST 7: Backward Compatibility (No Filtering)');
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
const execution7 = createTestExecution(10);
|
||||
const legacyResult = processExecution(execution7, {});
|
||||
|
||||
console.log('Legacy Result:');
|
||||
console.log('- Returns original execution:', legacyResult === execution7);
|
||||
console.log('- Type:', typeof legacyResult);
|
||||
console.log('\n✅ Backward compatible - no options returns original execution\n');
|
||||
|
||||
/**
|
||||
* Test 8: Input Data Inclusion
|
||||
*/
|
||||
console.log('🔗 TEST 8: Include Input Data');
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
const execution8 = createTestExecution(5);
|
||||
const inputDataResult = filterExecutionData(execution8, {
|
||||
mode: 'filtered',
|
||||
itemsLimit: 2,
|
||||
includeInputData: true,
|
||||
});
|
||||
|
||||
console.log('Input Data Result:');
|
||||
console.log('- Has input data:', !!inputDataResult.nodes?.['HTTP Request']?.data?.input);
|
||||
console.log('- Has output data:', !!inputDataResult.nodes?.['HTTP Request']?.data?.output);
|
||||
console.log('\n✅ Can include input data for debugging\n');
|
||||
|
||||
/**
|
||||
* Test 9: itemsLimit Validation
|
||||
*/
|
||||
console.log('⚠️ TEST 9: itemsLimit Validation');
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
const execution9 = createTestExecution(50);
|
||||
|
||||
// Test negative value
|
||||
const negativeResult = filterExecutionData(execution9, {
|
||||
mode: 'filtered',
|
||||
itemsLimit: -5,
|
||||
});
|
||||
console.log('- Negative itemsLimit (-5) handled:', negativeResult.nodes?.['HTTP Request']?.data?.metadata.itemsShown === 2);
|
||||
|
||||
// Test very large value
|
||||
const largeResult = filterExecutionData(execution9, {
|
||||
mode: 'filtered',
|
||||
itemsLimit: 999999,
|
||||
});
|
||||
console.log('- Large itemsLimit (999999) capped:', (largeResult.nodes?.['HTTP Request']?.data?.metadata.itemsShown || 0) <= 1000);
|
||||
|
||||
// Test unlimited (-1)
|
||||
const unlimitedResult = filterExecutionData(execution9, {
|
||||
mode: 'filtered',
|
||||
itemsLimit: -1,
|
||||
});
|
||||
console.log('- Unlimited itemsLimit (-1) works:', unlimitedResult.nodes?.['HTTP Request']?.data?.metadata.itemsShown === 50);
|
||||
|
||||
console.log('\n✅ itemsLimit validation works correctly\n');
|
||||
|
||||
/**
|
||||
* Test 10: Recommendation Following
|
||||
*/
|
||||
console.log('🎯 TEST 10: Follow Recommendation Workflow');
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
const execution10 = createTestExecution(100);
|
||||
const { preview: preview10, recommendation: rec10 } = generatePreview(execution10);
|
||||
|
||||
console.log('1. Preview shows:', {
|
||||
totalItems: preview10.nodes['HTTP Request']?.itemCounts.output,
|
||||
sizeKB: preview10.estimatedSizeKB,
|
||||
});
|
||||
|
||||
console.log('\n2. Recommendation:', {
|
||||
canFetchFull: rec10.canFetchFull,
|
||||
suggestedMode: rec10.suggestedMode,
|
||||
suggestedItemsLimit: rec10.suggestedItemsLimit,
|
||||
reason: rec10.reason,
|
||||
});
|
||||
|
||||
// Follow recommendation
|
||||
const options: ExecutionFilterOptions = {
|
||||
mode: rec10.suggestedMode,
|
||||
itemsLimit: rec10.suggestedItemsLimit,
|
||||
};
|
||||
|
||||
const recommendedResult = filterExecutionData(execution10, options);
|
||||
|
||||
console.log('\n3. Following recommendation gives:', {
|
||||
mode: recommendedResult.mode,
|
||||
itemsShown: recommendedResult.nodes?.['HTTP Request']?.data?.metadata.itemsShown,
|
||||
hasMoreData: recommendedResult.summary?.hasMoreData,
|
||||
});
|
||||
|
||||
console.log('\n✅ Recommendation workflow helps make optimal choices\n');
|
||||
|
||||
/**
|
||||
* Summary
|
||||
*/
|
||||
console.log('='.repeat(80));
|
||||
console.log('✨ All Tests Completed Successfully!');
|
||||
console.log('='.repeat(80));
|
||||
console.log('\n🎉 Execution Filtering Feature is Working!\n');
|
||||
console.log('Key Takeaways:');
|
||||
console.log('1. Always use preview mode first for unknown datasets');
|
||||
console.log('2. Follow the recommendation for optimal token usage');
|
||||
console.log('3. Use nodeNames to filter to relevant nodes');
|
||||
console.log('4. itemsLimit: 0 shows structure without data');
|
||||
console.log('5. itemsLimit: -1 returns unlimited items (use with caution)');
|
||||
console.log('6. Summary mode (2 items) is a safe default');
|
||||
console.log('7. Full mode should only be used for small datasets');
|
||||
console.log('');
|
||||
@@ -12,7 +12,7 @@ import { OperationSimilarityService } from './operation-similarity-service';
|
||||
import { ResourceSimilarityService } from './resource-similarity-service';
|
||||
import { NodeRepository } from '../database/node-repository';
|
||||
import { DatabaseAdapter } from '../database/database-adapter';
|
||||
import { normalizeNodeType } from '../utils/node-type-utils';
|
||||
import { NodeTypeNormalizer } from '../utils/node-type-normalizer';
|
||||
|
||||
export type ValidationMode = 'full' | 'operation' | 'minimal';
|
||||
export type ValidationProfile = 'strict' | 'runtime' | 'ai-friendly' | 'minimal';
|
||||
@@ -702,7 +702,7 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
||||
}
|
||||
|
||||
// Normalize the node type for repository lookups
|
||||
const normalizedNodeType = normalizeNodeType(nodeType);
|
||||
const normalizedNodeType = NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
|
||||
// Apply defaults for validation
|
||||
const configWithDefaults = { ...config };
|
||||
|
||||
519
src/services/execution-processor.ts
Normal file
519
src/services/execution-processor.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Execution Processor Service
|
||||
*
|
||||
* Intelligent processing and filtering of n8n execution data to enable
|
||||
* AI agents to inspect executions without exceeding token limits.
|
||||
*
|
||||
* Features:
|
||||
* - Preview mode: Show structure and counts without values
|
||||
* - Summary mode: Smart default with 2 sample items per node
|
||||
* - Filtered mode: Granular control (node filtering, item limits)
|
||||
* - Smart recommendations: Guide optimal retrieval strategy
|
||||
*/
|
||||
|
||||
import {
|
||||
Execution,
|
||||
ExecutionMode,
|
||||
ExecutionPreview,
|
||||
NodePreview,
|
||||
ExecutionRecommendation,
|
||||
ExecutionFilterOptions,
|
||||
FilteredExecutionResponse,
|
||||
FilteredNodeData,
|
||||
ExecutionStatus,
|
||||
} from '../types/n8n-api';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Size estimation and threshold constants
|
||||
*/
|
||||
const THRESHOLDS = {
|
||||
CHAR_SIZE_BYTES: 2, // UTF-16 characters
|
||||
OVERHEAD_PER_OBJECT: 50, // Approximate JSON overhead
|
||||
MAX_RECOMMENDED_SIZE_KB: 100, // Threshold for "can fetch full"
|
||||
SMALL_DATASET_ITEMS: 20, // <= this is considered small
|
||||
MODERATE_DATASET_ITEMS: 50, // <= this is considered moderate
|
||||
MODERATE_DATASET_SIZE_KB: 200, // <= this is considered moderate
|
||||
MAX_DEPTH: 3, // Maximum depth for structure extraction
|
||||
MAX_ITEMS_LIMIT: 1000, // Maximum allowed itemsLimit value
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Helper function to extract error message from various error formats
|
||||
*/
|
||||
function extractErrorMessage(error: unknown): string {
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
if (error && typeof error === 'object') {
|
||||
if ('message' in error && typeof error.message === 'string') {
|
||||
return error.message;
|
||||
}
|
||||
if ('error' in error && typeof error.error === 'string') {
|
||||
return error.error;
|
||||
}
|
||||
}
|
||||
return 'Unknown error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract data structure (JSON schema-like) from items
|
||||
*/
|
||||
function extractStructure(data: unknown, maxDepth = THRESHOLDS.MAX_DEPTH, currentDepth = 0): Record<string, unknown> | string | unknown[] {
|
||||
if (currentDepth >= maxDepth) {
|
||||
return typeof data;
|
||||
}
|
||||
|
||||
if (data === null || data === undefined) {
|
||||
return 'null';
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
// Extract structure from first item
|
||||
return [extractStructure(data[0], maxDepth, currentDepth + 1)];
|
||||
}
|
||||
|
||||
if (typeof data === 'object') {
|
||||
const structure: Record<string, unknown> = {};
|
||||
for (const key in data) {
|
||||
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
||||
structure[key] = extractStructure((data as Record<string, unknown>)[key], maxDepth, currentDepth + 1);
|
||||
}
|
||||
}
|
||||
return structure;
|
||||
}
|
||||
|
||||
return typeof data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate size of data in KB
|
||||
*/
|
||||
function estimateDataSize(data: unknown): number {
|
||||
try {
|
||||
const jsonString = JSON.stringify(data);
|
||||
const sizeBytes = jsonString.length * THRESHOLDS.CHAR_SIZE_BYTES;
|
||||
return Math.ceil(sizeBytes / 1024);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to estimate data size', { error });
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count items in execution data
|
||||
*/
|
||||
function countItems(nodeData: unknown): { input: number; output: number } {
|
||||
const counts = { input: 0, output: 0 };
|
||||
|
||||
if (!nodeData || !Array.isArray(nodeData)) {
|
||||
return counts;
|
||||
}
|
||||
|
||||
for (const run of nodeData) {
|
||||
if (run?.data?.main) {
|
||||
const mainData = run.data.main;
|
||||
if (Array.isArray(mainData)) {
|
||||
for (const output of mainData) {
|
||||
if (Array.isArray(output)) {
|
||||
counts.output += output.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate preview for an execution
|
||||
*/
|
||||
export function generatePreview(execution: Execution): {
|
||||
preview: ExecutionPreview;
|
||||
recommendation: ExecutionRecommendation;
|
||||
} {
|
||||
const preview: ExecutionPreview = {
|
||||
totalNodes: 0,
|
||||
executedNodes: 0,
|
||||
estimatedSizeKB: 0,
|
||||
nodes: {},
|
||||
};
|
||||
|
||||
if (!execution.data?.resultData?.runData) {
|
||||
return {
|
||||
preview,
|
||||
recommendation: {
|
||||
canFetchFull: true,
|
||||
suggestedMode: 'summary',
|
||||
reason: 'No execution data available',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const runData = execution.data.resultData.runData;
|
||||
const nodeNames = Object.keys(runData);
|
||||
preview.totalNodes = nodeNames.length;
|
||||
|
||||
let totalItemsOutput = 0;
|
||||
let largestNodeItems = 0;
|
||||
|
||||
for (const nodeName of nodeNames) {
|
||||
const nodeData = runData[nodeName];
|
||||
const itemCounts = countItems(nodeData);
|
||||
|
||||
// Extract structure from first run's first output item
|
||||
let dataStructure: Record<string, unknown> = {};
|
||||
if (Array.isArray(nodeData) && nodeData.length > 0) {
|
||||
const firstRun = nodeData[0];
|
||||
const firstItem = firstRun?.data?.main?.[0]?.[0];
|
||||
if (firstItem) {
|
||||
dataStructure = extractStructure(firstItem) as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
const nodeSize = estimateDataSize(nodeData);
|
||||
|
||||
const nodePreview: NodePreview = {
|
||||
status: 'success',
|
||||
itemCounts,
|
||||
dataStructure,
|
||||
estimatedSizeKB: nodeSize,
|
||||
};
|
||||
|
||||
// Check for errors
|
||||
if (Array.isArray(nodeData)) {
|
||||
for (const run of nodeData) {
|
||||
if (run.error) {
|
||||
nodePreview.status = 'error';
|
||||
nodePreview.error = extractErrorMessage(run.error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
preview.nodes[nodeName] = nodePreview;
|
||||
preview.estimatedSizeKB += nodeSize;
|
||||
preview.executedNodes++;
|
||||
totalItemsOutput += itemCounts.output;
|
||||
largestNodeItems = Math.max(largestNodeItems, itemCounts.output);
|
||||
}
|
||||
|
||||
// Generate recommendation
|
||||
const recommendation = generateRecommendation(
|
||||
preview.estimatedSizeKB,
|
||||
totalItemsOutput,
|
||||
largestNodeItems
|
||||
);
|
||||
|
||||
return { preview, recommendation };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate smart recommendation based on data characteristics
|
||||
*/
|
||||
function generateRecommendation(
|
||||
totalSizeKB: number,
|
||||
totalItems: number,
|
||||
largestNodeItems: number
|
||||
): ExecutionRecommendation {
|
||||
// Can safely fetch full data
|
||||
if (totalSizeKB <= THRESHOLDS.MAX_RECOMMENDED_SIZE_KB && totalItems <= THRESHOLDS.SMALL_DATASET_ITEMS) {
|
||||
return {
|
||||
canFetchFull: true,
|
||||
suggestedMode: 'full',
|
||||
reason: `Small dataset (${totalSizeKB}KB, ${totalItems} items). Safe to fetch full data.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Moderate size - use summary
|
||||
if (totalSizeKB <= THRESHOLDS.MODERATE_DATASET_SIZE_KB && totalItems <= THRESHOLDS.MODERATE_DATASET_ITEMS) {
|
||||
return {
|
||||
canFetchFull: false,
|
||||
suggestedMode: 'summary',
|
||||
suggestedItemsLimit: 2,
|
||||
reason: `Moderate dataset (${totalSizeKB}KB, ${totalItems} items). Summary mode recommended.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Large dataset - filter with limits
|
||||
const suggestedLimit = Math.max(1, Math.min(5, Math.floor(100 / largestNodeItems)));
|
||||
|
||||
return {
|
||||
canFetchFull: false,
|
||||
suggestedMode: 'filtered',
|
||||
suggestedItemsLimit: suggestedLimit,
|
||||
reason: `Large dataset (${totalSizeKB}KB, ${totalItems} items). Use filtered mode with itemsLimit: ${suggestedLimit}.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate items array with metadata
|
||||
*/
|
||||
function truncateItems(
|
||||
items: unknown[][],
|
||||
limit: number
|
||||
): {
|
||||
truncated: unknown[][];
|
||||
metadata: { totalItems: number; itemsShown: number; truncated: boolean };
|
||||
} {
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return {
|
||||
truncated: items || [],
|
||||
metadata: {
|
||||
totalItems: 0,
|
||||
itemsShown: 0,
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let totalItems = 0;
|
||||
for (const output of items) {
|
||||
if (Array.isArray(output)) {
|
||||
totalItems += output.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Special case: limit = 0 means structure only
|
||||
if (limit === 0) {
|
||||
const structureOnly = items.map(output => {
|
||||
if (!Array.isArray(output) || output.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [extractStructure(output[0])];
|
||||
});
|
||||
|
||||
return {
|
||||
truncated: structureOnly,
|
||||
metadata: {
|
||||
totalItems,
|
||||
itemsShown: 0,
|
||||
truncated: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Limit = -1 means unlimited
|
||||
if (limit < 0) {
|
||||
return {
|
||||
truncated: items,
|
||||
metadata: {
|
||||
totalItems,
|
||||
itemsShown: totalItems,
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Apply limit
|
||||
const result: unknown[][] = [];
|
||||
let itemsShown = 0;
|
||||
|
||||
for (const output of items) {
|
||||
if (!Array.isArray(output)) {
|
||||
result.push(output);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (itemsShown >= limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
const remaining = limit - itemsShown;
|
||||
const toTake = Math.min(remaining, output.length);
|
||||
result.push(output.slice(0, toTake));
|
||||
itemsShown += toTake;
|
||||
}
|
||||
|
||||
return {
|
||||
truncated: result,
|
||||
metadata: {
|
||||
totalItems,
|
||||
itemsShown,
|
||||
truncated: itemsShown < totalItems,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter execution data based on options
|
||||
*/
|
||||
export function filterExecutionData(
|
||||
execution: Execution,
|
||||
options: ExecutionFilterOptions
|
||||
): FilteredExecutionResponse {
|
||||
const mode = options.mode || 'summary';
|
||||
|
||||
// Validate and bound itemsLimit
|
||||
let itemsLimit = options.itemsLimit !== undefined ? options.itemsLimit : 2;
|
||||
if (itemsLimit !== -1) { // -1 means unlimited
|
||||
if (itemsLimit < 0) {
|
||||
logger.warn('Invalid itemsLimit, defaulting to 2', { provided: itemsLimit });
|
||||
itemsLimit = 2;
|
||||
}
|
||||
if (itemsLimit > THRESHOLDS.MAX_ITEMS_LIMIT) {
|
||||
logger.warn(`itemsLimit capped at ${THRESHOLDS.MAX_ITEMS_LIMIT}`, { provided: itemsLimit });
|
||||
itemsLimit = THRESHOLDS.MAX_ITEMS_LIMIT;
|
||||
}
|
||||
}
|
||||
|
||||
const includeInputData = options.includeInputData || false;
|
||||
const nodeNamesFilter = options.nodeNames;
|
||||
|
||||
// Calculate duration
|
||||
const duration = execution.stoppedAt && execution.startedAt
|
||||
? new Date(execution.stoppedAt).getTime() - new Date(execution.startedAt).getTime()
|
||||
: undefined;
|
||||
|
||||
const response: FilteredExecutionResponse = {
|
||||
id: execution.id,
|
||||
workflowId: execution.workflowId,
|
||||
status: execution.status,
|
||||
mode,
|
||||
startedAt: execution.startedAt,
|
||||
stoppedAt: execution.stoppedAt,
|
||||
duration,
|
||||
finished: execution.finished,
|
||||
};
|
||||
|
||||
// Handle preview mode
|
||||
if (mode === 'preview') {
|
||||
const { preview, recommendation } = generatePreview(execution);
|
||||
response.preview = preview;
|
||||
response.recommendation = recommendation;
|
||||
return response;
|
||||
}
|
||||
|
||||
// Handle no data case
|
||||
if (!execution.data?.resultData?.runData) {
|
||||
response.summary = {
|
||||
totalNodes: 0,
|
||||
executedNodes: 0,
|
||||
totalItems: 0,
|
||||
hasMoreData: false,
|
||||
};
|
||||
response.nodes = {};
|
||||
|
||||
if (execution.data?.resultData?.error) {
|
||||
response.error = execution.data.resultData.error;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
const runData = execution.data.resultData.runData;
|
||||
let nodeNames = Object.keys(runData);
|
||||
|
||||
// Apply node name filter
|
||||
if (nodeNamesFilter && nodeNamesFilter.length > 0) {
|
||||
nodeNames = nodeNames.filter(name => nodeNamesFilter.includes(name));
|
||||
}
|
||||
|
||||
// Process nodes
|
||||
const processedNodes: Record<string, FilteredNodeData> = {};
|
||||
let totalItems = 0;
|
||||
let hasMoreData = false;
|
||||
|
||||
for (const nodeName of nodeNames) {
|
||||
const nodeData = runData[nodeName];
|
||||
|
||||
if (!Array.isArray(nodeData) || nodeData.length === 0) {
|
||||
processedNodes[nodeName] = {
|
||||
itemsInput: 0,
|
||||
itemsOutput: 0,
|
||||
status: 'success',
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get first run data
|
||||
const firstRun = nodeData[0];
|
||||
const itemCounts = countItems(nodeData);
|
||||
totalItems += itemCounts.output;
|
||||
|
||||
const nodeResult: FilteredNodeData = {
|
||||
executionTime: firstRun.executionTime,
|
||||
itemsInput: itemCounts.input,
|
||||
itemsOutput: itemCounts.output,
|
||||
status: 'success',
|
||||
};
|
||||
|
||||
// Check for errors
|
||||
if (firstRun.error) {
|
||||
nodeResult.status = 'error';
|
||||
nodeResult.error = extractErrorMessage(firstRun.error);
|
||||
}
|
||||
|
||||
// Handle full mode - include all data
|
||||
if (mode === 'full') {
|
||||
nodeResult.data = {
|
||||
output: firstRun.data?.main || [],
|
||||
metadata: {
|
||||
totalItems: itemCounts.output,
|
||||
itemsShown: itemCounts.output,
|
||||
truncated: false,
|
||||
},
|
||||
};
|
||||
|
||||
if (includeInputData && firstRun.inputData) {
|
||||
nodeResult.data.input = firstRun.inputData;
|
||||
}
|
||||
} else {
|
||||
// Summary or filtered mode - apply limits
|
||||
const outputData = firstRun.data?.main || [];
|
||||
const { truncated, metadata } = truncateItems(outputData, itemsLimit);
|
||||
|
||||
if (metadata.truncated) {
|
||||
hasMoreData = true;
|
||||
}
|
||||
|
||||
nodeResult.data = {
|
||||
output: truncated,
|
||||
metadata,
|
||||
};
|
||||
|
||||
if (includeInputData && firstRun.inputData) {
|
||||
nodeResult.data.input = firstRun.inputData;
|
||||
}
|
||||
}
|
||||
|
||||
processedNodes[nodeName] = nodeResult;
|
||||
}
|
||||
|
||||
// Add summary
|
||||
response.summary = {
|
||||
totalNodes: Object.keys(runData).length,
|
||||
executedNodes: nodeNames.length,
|
||||
totalItems,
|
||||
hasMoreData,
|
||||
};
|
||||
|
||||
response.nodes = processedNodes;
|
||||
|
||||
// Include error if present
|
||||
if (execution.data?.resultData?.error) {
|
||||
response.error = execution.data.resultData.error;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process execution based on mode and options
|
||||
* Main entry point for the service
|
||||
*/
|
||||
export function processExecution(
|
||||
execution: Execution,
|
||||
options: ExecutionFilterOptions = {}
|
||||
): FilteredExecutionResponse | Execution {
|
||||
// Legacy behavior: if no mode specified and no filtering options, return original
|
||||
if (!options.mode && !options.nodeNames && options.itemsLimit === undefined) {
|
||||
return execution;
|
||||
}
|
||||
|
||||
return filterExecutionData(execution, options);
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export const workflowSettingsSchema = z.object({
|
||||
saveExecutionProgress: z.boolean().default(true),
|
||||
executionTimeout: z.number().optional(),
|
||||
errorWorkflow: z.string().optional(),
|
||||
callerPolicy: z.enum(['any', 'workflowsFromSameOwner', 'workflowsFromAList']).optional(),
|
||||
});
|
||||
|
||||
// Default settings for workflow creation
|
||||
@@ -95,14 +96,17 @@ export function cleanWorkflowForCreate(workflow: Partial<Workflow>): Partial<Wor
|
||||
|
||||
/**
|
||||
* Clean workflow data for update operations.
|
||||
*
|
||||
*
|
||||
* This function removes read-only and computed fields that should not be sent
|
||||
* in API update requests. It does NOT add any default values or new fields.
|
||||
*
|
||||
*
|
||||
* Note: Unlike cleanWorkflowForCreate, this function does not add default settings.
|
||||
* The n8n API will reject update requests that include properties not present in
|
||||
* the original workflow ("settings must NOT have additional properties" error).
|
||||
*
|
||||
*
|
||||
* Settings are filtered to only include whitelisted properties to prevent API
|
||||
* errors when workflows from n8n contain UI-only or deprecated properties.
|
||||
*
|
||||
* @param workflow - The workflow object to clean
|
||||
* @returns A cleaned partial workflow suitable for API updates
|
||||
*/
|
||||
@@ -129,6 +133,25 @@ export function cleanWorkflowForUpdate(workflow: Workflow): Partial<Workflow> {
|
||||
...cleanedWorkflow
|
||||
} = workflow as any;
|
||||
|
||||
// CRITICAL FIX for Issue #248:
|
||||
// The n8n API has version-specific behavior for settings in workflow updates:
|
||||
//
|
||||
// PROBLEM:
|
||||
// - Some versions reject updates with settings properties (community forum reports)
|
||||
// - Cloud versions REQUIRE settings property to be present (n8n.estyl.team)
|
||||
// - Properties like callerPolicy and executionOrder cause "additional properties" errors
|
||||
//
|
||||
// SOLUTION:
|
||||
// - ALWAYS set settings to empty object {}, regardless of whether it exists
|
||||
// - Empty object satisfies "required property" validation (cloud API)
|
||||
// - Empty object has no "additional properties" to trigger errors (self-hosted)
|
||||
// - n8n API interprets empty settings as "no changes" and preserves existing settings
|
||||
//
|
||||
// References:
|
||||
// - https://community.n8n.io/t/api-workflow-update-endpoint-doesnt-support-setting-callerpolicy/161916
|
||||
// - Tested on n8n.estyl.team (cloud) and localhost (self-hosted)
|
||||
cleanedWorkflow.settings = {};
|
||||
|
||||
return cleanedWorkflow;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
/**
|
||||
* Task Templates Service
|
||||
*
|
||||
*
|
||||
* @deprecated This module is deprecated as of v2.15.0 and will be removed in v2.16.0.
|
||||
* The get_node_for_task tool has been removed in favor of template-based configuration examples.
|
||||
*
|
||||
* Migration:
|
||||
* - Use `search_nodes({query: "webhook", includeExamples: true})` to find nodes with real template configs
|
||||
* - Use `get_node_essentials({nodeType: "nodes-base.webhook", includeExamples: true})` for top 3 examples
|
||||
* - New approach provides 2,646 real templates vs 31 hardcoded tasks
|
||||
*
|
||||
* Provides pre-configured node settings for common tasks.
|
||||
* This helps AI agents quickly configure nodes for specific use cases.
|
||||
*/
|
||||
|
||||
@@ -362,16 +362,36 @@ export class WorkflowDiffEngine {
|
||||
|
||||
// Connection operation validators
|
||||
private validateAddConnection(workflow: Workflow, operation: AddConnectionOperation): string | null {
|
||||
// Check for common parameter mistakes (Issue #249)
|
||||
const operationAny = operation as any;
|
||||
if (operationAny.sourceNodeId || operationAny.targetNodeId) {
|
||||
const wrongParams: string[] = [];
|
||||
if (operationAny.sourceNodeId) wrongParams.push('sourceNodeId');
|
||||
if (operationAny.targetNodeId) wrongParams.push('targetNodeId');
|
||||
|
||||
return `Invalid parameter(s): ${wrongParams.join(', ')}. Use 'source' and 'target' instead. Example: {type: "addConnection", source: "Node Name", target: "Target Name"}`;
|
||||
}
|
||||
|
||||
// Check for missing required parameters
|
||||
if (!operation.source) {
|
||||
return `Missing required parameter 'source'. The addConnection operation requires both 'source' and 'target' parameters. Check that you're using 'source' (not 'sourceNodeId').`;
|
||||
}
|
||||
if (!operation.target) {
|
||||
return `Missing required parameter 'target'. The addConnection operation requires both 'source' and 'target' parameters. Check that you're using 'target' (not 'targetNodeId').`;
|
||||
}
|
||||
|
||||
const sourceNode = this.findNode(workflow, operation.source, operation.source);
|
||||
const targetNode = this.findNode(workflow, operation.target, operation.target);
|
||||
|
||||
|
||||
if (!sourceNode) {
|
||||
return `Source node not found: ${operation.source}`;
|
||||
const availableNodes = workflow.nodes.map(n => n.name).join(', ');
|
||||
return `Source node not found: "${operation.source}". Available nodes: ${availableNodes}`;
|
||||
}
|
||||
if (!targetNode) {
|
||||
return `Target node not found: ${operation.target}`;
|
||||
const availableNodes = workflow.nodes.map(n => n.name).join(', ');
|
||||
return `Target node not found: "${operation.target}". Available nodes: ${availableNodes}`;
|
||||
}
|
||||
|
||||
|
||||
// Check if connection already exists
|
||||
const sourceOutput = operation.sourceOutput || 'main';
|
||||
const existing = workflow.connections[sourceNode.name]?.[sourceOutput];
|
||||
@@ -383,7 +403,7 @@ export class WorkflowDiffEngine {
|
||||
return `Connection already exists from "${sourceNode.name}" to "${targetNode.name}"`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { EnhancedConfigValidator } from './enhanced-config-validator';
|
||||
import { ExpressionValidator } from './expression-validator';
|
||||
import { ExpressionFormatValidator } from './expression-format-validator';
|
||||
import { NodeSimilarityService, NodeSuggestion } from './node-similarity-service';
|
||||
import { normalizeNodeType } from '../utils/node-type-utils';
|
||||
import { NodeTypeNormalizer } from '../utils/node-type-normalizer';
|
||||
import { Logger } from '../utils/logger';
|
||||
const logger = new Logger({ prefix: '[WorkflowValidator]' });
|
||||
|
||||
@@ -247,7 +247,7 @@ export class WorkflowValidator {
|
||||
// Check for minimum viable workflow
|
||||
if (workflow.nodes.length === 1) {
|
||||
const singleNode = workflow.nodes[0];
|
||||
const normalizedType = normalizeNodeType(singleNode.type);
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(singleNode.type);
|
||||
const isWebhook = normalizedType === 'nodes-base.webhook' ||
|
||||
normalizedType === 'nodes-base.webhookTrigger';
|
||||
|
||||
@@ -304,7 +304,7 @@ export class WorkflowValidator {
|
||||
|
||||
// Count trigger nodes - normalize type names first
|
||||
const triggerNodes = workflow.nodes.filter(n => {
|
||||
const normalizedType = normalizeNodeType(n.type);
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(n.type);
|
||||
return normalizedType.toLowerCase().includes('trigger') ||
|
||||
normalizedType.toLowerCase().includes('webhook') ||
|
||||
normalizedType === 'nodes-base.start' ||
|
||||
@@ -364,17 +364,17 @@ export class WorkflowValidator {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Get node definition - try multiple formats
|
||||
let nodeInfo = this.nodeRepository.getNode(node.type);
|
||||
// Normalize node type FIRST to ensure consistent lookup
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(node.type);
|
||||
|
||||
// If not found, try with normalized type
|
||||
if (!nodeInfo) {
|
||||
const normalizedType = normalizeNodeType(node.type);
|
||||
if (normalizedType !== node.type) {
|
||||
nodeInfo = this.nodeRepository.getNode(normalizedType);
|
||||
}
|
||||
// Update node type in place if it was normalized
|
||||
if (normalizedType !== node.type) {
|
||||
node.type = normalizedType;
|
||||
}
|
||||
|
||||
|
||||
// Get node definition using normalized type
|
||||
const nodeInfo = this.nodeRepository.getNode(normalizedType);
|
||||
|
||||
if (!nodeInfo) {
|
||||
// Use NodeSimilarityService to find suggestions
|
||||
const suggestions = await this.similarityService.findSimilarNodes(node.type, 3);
|
||||
@@ -597,8 +597,8 @@ export class WorkflowValidator {
|
||||
// Check for orphaned nodes (exclude sticky notes)
|
||||
for (const node of workflow.nodes) {
|
||||
if (node.disabled || this.isStickyNote(node)) continue;
|
||||
|
||||
const normalizedType = normalizeNodeType(node.type);
|
||||
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(node.type);
|
||||
const isTrigger = normalizedType.toLowerCase().includes('trigger') ||
|
||||
normalizedType.toLowerCase().includes('webhook') ||
|
||||
normalizedType === 'nodes-base.start' ||
|
||||
@@ -658,7 +658,7 @@ export class WorkflowValidator {
|
||||
}
|
||||
|
||||
// Special validation for SplitInBatches node
|
||||
if (sourceNode && sourceNode.type === 'n8n-nodes-base.splitInBatches') {
|
||||
if (sourceNode && sourceNode.type === 'nodes-base.splitInBatches') {
|
||||
this.validateSplitInBatchesConnection(
|
||||
sourceNode,
|
||||
outputIndex,
|
||||
@@ -671,7 +671,7 @@ export class WorkflowValidator {
|
||||
// Check for self-referencing connections
|
||||
if (connection.node === sourceName) {
|
||||
// This is only a warning for non-loop nodes
|
||||
if (sourceNode && sourceNode.type !== 'n8n-nodes-base.splitInBatches') {
|
||||
if (sourceNode && sourceNode.type !== 'nodes-base.splitInBatches') {
|
||||
result.warnings.push({
|
||||
type: 'warning',
|
||||
message: `Node "${sourceName}" has a self-referencing connection. This can cause infinite loops.`
|
||||
@@ -811,14 +811,12 @@ export class WorkflowValidator {
|
||||
// The source should be an AI Agent connecting to this target node as a tool
|
||||
|
||||
// Get target node info to check if it can be used as a tool
|
||||
let targetNodeInfo = this.nodeRepository.getNode(targetNode.type);
|
||||
|
||||
// Try normalized type if not found
|
||||
if (!targetNodeInfo) {
|
||||
const normalizedType = normalizeNodeType(targetNode.type);
|
||||
if (normalizedType !== targetNode.type) {
|
||||
targetNodeInfo = this.nodeRepository.getNode(normalizedType);
|
||||
}
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(targetNode.type);
|
||||
let targetNodeInfo = this.nodeRepository.getNode(normalizedType);
|
||||
|
||||
// Try original type if normalization didn't help (fallback for edge cases)
|
||||
if (!targetNodeInfo && normalizedType !== targetNode.type) {
|
||||
targetNodeInfo = this.nodeRepository.getNode(targetNode.type);
|
||||
}
|
||||
|
||||
if (targetNodeInfo && !targetNodeInfo.isAITool && targetNodeInfo.package !== 'n8n-nodes-base') {
|
||||
|
||||
@@ -127,7 +127,7 @@ export class TelemetryEventTracker {
|
||||
/**
|
||||
* Track an error event
|
||||
*/
|
||||
trackError(errorType: string, context: string, toolName?: string): void {
|
||||
trackError(errorType: string, context: string, toolName?: string, errorMessage?: string): void {
|
||||
if (!this.isEnabled()) return;
|
||||
|
||||
// Don't rate limit error tracking - we want to see all errors
|
||||
@@ -135,6 +135,7 @@ export class TelemetryEventTracker {
|
||||
errorType: this.sanitizeErrorType(errorType),
|
||||
context: this.sanitizeContext(context),
|
||||
tool: toolName ? toolName.replace(/[^a-zA-Z0-9_-]/g, '_') : undefined,
|
||||
error: errorMessage ? this.sanitizeErrorMessage(errorMessage) : undefined,
|
||||
}, false); // Skip rate limiting for errors
|
||||
}
|
||||
|
||||
@@ -428,4 +429,56 @@ export class TelemetryEventTracker {
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize error message
|
||||
*/
|
||||
private sanitizeErrorMessage(errorMessage: string): string {
|
||||
try {
|
||||
// Early truncate to prevent ReDoS and performance issues
|
||||
const maxLength = 1500;
|
||||
const trimmed = errorMessage.length > maxLength
|
||||
? errorMessage.substring(0, maxLength)
|
||||
: errorMessage;
|
||||
|
||||
// Handle stack traces - keep only first 3 lines (message + top stack frames)
|
||||
const lines = trimmed.split('\n');
|
||||
let sanitized = lines.slice(0, 3).join('\n');
|
||||
|
||||
// Sanitize sensitive data in correct order to prevent leakage
|
||||
// 1. URLs first (most encompassing) - fully redact to prevent path leakage
|
||||
sanitized = sanitized.replace(/https?:\/\/\S+/gi, '[URL]');
|
||||
|
||||
// 2. Specific credential patterns (before generic patterns)
|
||||
sanitized = sanitized
|
||||
.replace(/AKIA[A-Z0-9]{16}/g, '[AWS_KEY]')
|
||||
.replace(/ghp_[a-zA-Z0-9]{36,}/g, '[GITHUB_TOKEN]')
|
||||
.replace(/eyJ[a-zA-Z0-9_-]+\.eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+/g, '[JWT]')
|
||||
.replace(/Bearer\s+[^\s]+/gi, 'Bearer [TOKEN]');
|
||||
|
||||
// 3. Emails (after URLs to avoid partial matches)
|
||||
sanitized = sanitized.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '[EMAIL]');
|
||||
|
||||
// 4. Long keys and quoted tokens
|
||||
sanitized = sanitized
|
||||
.replace(/\b[a-zA-Z0-9_-]{32,}\b/g, '[KEY]')
|
||||
.replace(/(['"])[a-zA-Z0-9_-]{16,}\1/g, '$1[TOKEN]$1');
|
||||
|
||||
// 5. Generic credential patterns (after specific ones to avoid conflicts)
|
||||
sanitized = sanitized
|
||||
.replace(/password\s*[=:]\s*\S+/gi, 'password=[REDACTED]')
|
||||
.replace(/api[_-]?key\s*[=:]\s*\S+/gi, 'api_key=[REDACTED]')
|
||||
.replace(/(?<!Bearer\s)token\s*[=:]\s*\S+/gi, 'token=[REDACTED]'); // Negative lookbehind to avoid Bearer tokens
|
||||
|
||||
// Final truncate to 500 chars
|
||||
if (sanitized.length > 500) {
|
||||
sanitized = sanitized.substring(0, 500) + '...';
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
} catch (error) {
|
||||
logger.debug('Error message sanitization failed:', error);
|
||||
return '[SANITIZATION_FAILED]';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,9 +152,9 @@ export class TelemetryManager {
|
||||
/**
|
||||
* Track an error event
|
||||
*/
|
||||
trackError(errorType: string, context: string, toolName?: string): void {
|
||||
trackError(errorType: string, context: string, toolName?: string, errorMessage?: string): void {
|
||||
this.ensureInitialized();
|
||||
this.eventTracker.trackError(errorType, context, toolName);
|
||||
this.eventTracker.trackError(errorType, context, toolName, errorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -625,7 +625,65 @@ export class TemplateRepository {
|
||||
|
||||
return { total, withMetadata, withoutMetadata, outdated };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Build WHERE conditions for metadata filtering
|
||||
* @private
|
||||
* @returns Object containing SQL conditions array and parameter values array
|
||||
*/
|
||||
private buildMetadataFilterConditions(filters: {
|
||||
category?: string;
|
||||
complexity?: 'simple' | 'medium' | 'complex';
|
||||
maxSetupMinutes?: number;
|
||||
minSetupMinutes?: number;
|
||||
requiredService?: string;
|
||||
targetAudience?: string;
|
||||
}): { conditions: string[], params: any[] } {
|
||||
const conditions: string[] = ['metadata_json IS NOT NULL'];
|
||||
const params: any[] = [];
|
||||
|
||||
if (filters.category !== undefined) {
|
||||
// Use parameterized LIKE with JSON array search - safe from injection
|
||||
conditions.push("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
|
||||
// Escape special characters and quotes for JSON string matching
|
||||
const sanitizedCategory = JSON.stringify(filters.category).slice(1, -1);
|
||||
params.push(sanitizedCategory);
|
||||
}
|
||||
|
||||
if (filters.complexity) {
|
||||
conditions.push("json_extract(metadata_json, '$.complexity') = ?");
|
||||
params.push(filters.complexity);
|
||||
}
|
||||
|
||||
if (filters.maxSetupMinutes !== undefined) {
|
||||
conditions.push("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?");
|
||||
params.push(filters.maxSetupMinutes);
|
||||
}
|
||||
|
||||
if (filters.minSetupMinutes !== undefined) {
|
||||
conditions.push("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?");
|
||||
params.push(filters.minSetupMinutes);
|
||||
}
|
||||
|
||||
if (filters.requiredService !== undefined) {
|
||||
// Use parameterized LIKE with JSON array search - safe from injection
|
||||
conditions.push("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
|
||||
// Escape special characters and quotes for JSON string matching
|
||||
const sanitizedService = JSON.stringify(filters.requiredService).slice(1, -1);
|
||||
params.push(sanitizedService);
|
||||
}
|
||||
|
||||
if (filters.targetAudience !== undefined) {
|
||||
// Use parameterized LIKE with JSON array search - safe from injection
|
||||
conditions.push("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
|
||||
// Escape special characters and quotes for JSON string matching
|
||||
const sanitizedAudience = JSON.stringify(filters.targetAudience).slice(1, -1);
|
||||
params.push(sanitizedAudience);
|
||||
}
|
||||
|
||||
return { conditions, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Search templates by metadata fields
|
||||
*/
|
||||
@@ -637,60 +695,72 @@ export class TemplateRepository {
|
||||
requiredService?: string;
|
||||
targetAudience?: string;
|
||||
}, limit: number = 20, offset: number = 0): StoredTemplate[] {
|
||||
const conditions: string[] = ['metadata_json IS NOT NULL'];
|
||||
const params: any[] = [];
|
||||
|
||||
// Build WHERE conditions based on filters with proper parameterization
|
||||
if (filters.category !== undefined) {
|
||||
// Use parameterized LIKE with JSON array search - safe from injection
|
||||
conditions.push("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
|
||||
// Escape special characters and quotes for JSON string matching
|
||||
const sanitizedCategory = JSON.stringify(filters.category).slice(1, -1);
|
||||
params.push(sanitizedCategory);
|
||||
}
|
||||
|
||||
if (filters.complexity) {
|
||||
conditions.push("json_extract(metadata_json, '$.complexity') = ?");
|
||||
params.push(filters.complexity);
|
||||
}
|
||||
|
||||
if (filters.maxSetupMinutes !== undefined) {
|
||||
conditions.push("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?");
|
||||
params.push(filters.maxSetupMinutes);
|
||||
}
|
||||
|
||||
if (filters.minSetupMinutes !== undefined) {
|
||||
conditions.push("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?");
|
||||
params.push(filters.minSetupMinutes);
|
||||
}
|
||||
|
||||
if (filters.requiredService !== undefined) {
|
||||
// Use parameterized LIKE with JSON array search - safe from injection
|
||||
conditions.push("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
|
||||
// Escape special characters and quotes for JSON string matching
|
||||
const sanitizedService = JSON.stringify(filters.requiredService).slice(1, -1);
|
||||
params.push(sanitizedService);
|
||||
}
|
||||
|
||||
if (filters.targetAudience !== undefined) {
|
||||
// Use parameterized LIKE with JSON array search - safe from injection
|
||||
conditions.push("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
|
||||
// Escape special characters and quotes for JSON string matching
|
||||
const sanitizedAudience = JSON.stringify(filters.targetAudience).slice(1, -1);
|
||||
params.push(sanitizedAudience);
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT * FROM templates
|
||||
const startTime = Date.now();
|
||||
|
||||
// Build WHERE conditions using shared helper
|
||||
const { conditions, params } = this.buildMetadataFilterConditions(filters);
|
||||
|
||||
// Performance optimization: Use two-phase query to avoid loading large compressed workflows
|
||||
// during metadata filtering. This prevents timeout when no filters are provided.
|
||||
// Phase 1: Get IDs only with metadata filtering (fast - no workflow data)
|
||||
// Add id to ORDER BY to ensure stable ordering
|
||||
const idsQuery = `
|
||||
SELECT id FROM templates
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY views DESC, created_at DESC
|
||||
ORDER BY views DESC, created_at DESC, id ASC
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
|
||||
|
||||
params.push(limit, offset);
|
||||
const results = this.db.prepare(query).all(...params) as StoredTemplate[];
|
||||
|
||||
logger.debug(`Metadata search found ${results.length} results`, { filters, count: results.length });
|
||||
const ids = this.db.prepare(idsQuery).all(...params) as { id: number }[];
|
||||
|
||||
const phase1Time = Date.now() - startTime;
|
||||
|
||||
if (ids.length === 0) {
|
||||
logger.debug('Metadata search found 0 results', { filters, phase1Ms: phase1Time });
|
||||
return [];
|
||||
}
|
||||
|
||||
// Defensive validation: ensure all IDs are valid positive integers
|
||||
const idValues = ids.map(r => r.id).filter(id => typeof id === 'number' && id > 0 && Number.isInteger(id));
|
||||
|
||||
if (idValues.length === 0) {
|
||||
logger.warn('No valid IDs after filtering', { filters, originalCount: ids.length });
|
||||
return [];
|
||||
}
|
||||
|
||||
if (idValues.length !== ids.length) {
|
||||
logger.warn('Some IDs were filtered out as invalid', {
|
||||
original: ids.length,
|
||||
valid: idValues.length,
|
||||
filtered: ids.length - idValues.length
|
||||
});
|
||||
}
|
||||
|
||||
// Phase 2: Fetch full records preserving exact order from Phase 1
|
||||
// Use CTE with VALUES to maintain ordering without depending on SQLite's IN clause behavior
|
||||
const phase2Start = Date.now();
|
||||
const orderedQuery = `
|
||||
WITH ordered_ids(id, sort_order) AS (
|
||||
VALUES ${idValues.map((id, idx) => `(${id}, ${idx})`).join(', ')}
|
||||
)
|
||||
SELECT t.* FROM templates t
|
||||
INNER JOIN ordered_ids o ON t.id = o.id
|
||||
ORDER BY o.sort_order
|
||||
`;
|
||||
|
||||
const results = this.db.prepare(orderedQuery).all() as StoredTemplate[];
|
||||
const phase2Time = Date.now() - phase2Start;
|
||||
|
||||
logger.debug(`Metadata search found ${results.length} results`, {
|
||||
filters,
|
||||
count: results.length,
|
||||
phase1Ms: phase1Time,
|
||||
phase2Ms: phase2Time,
|
||||
totalMs: Date.now() - startTime,
|
||||
optimization: 'two-phase-with-ordering'
|
||||
});
|
||||
|
||||
return results.map(t => this.decompressWorkflow(t));
|
||||
}
|
||||
|
||||
@@ -705,48 +775,12 @@ export class TemplateRepository {
|
||||
requiredService?: string;
|
||||
targetAudience?: string;
|
||||
}): number {
|
||||
const conditions: string[] = ['metadata_json IS NOT NULL'];
|
||||
const params: any[] = [];
|
||||
|
||||
if (filters.category !== undefined) {
|
||||
// Use parameterized LIKE with JSON array search - safe from injection
|
||||
conditions.push("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
|
||||
const sanitizedCategory = JSON.stringify(filters.category).slice(1, -1);
|
||||
params.push(sanitizedCategory);
|
||||
}
|
||||
|
||||
if (filters.complexity) {
|
||||
conditions.push("json_extract(metadata_json, '$.complexity') = ?");
|
||||
params.push(filters.complexity);
|
||||
}
|
||||
|
||||
if (filters.maxSetupMinutes !== undefined) {
|
||||
conditions.push("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?");
|
||||
params.push(filters.maxSetupMinutes);
|
||||
}
|
||||
|
||||
if (filters.minSetupMinutes !== undefined) {
|
||||
conditions.push("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?");
|
||||
params.push(filters.minSetupMinutes);
|
||||
}
|
||||
|
||||
if (filters.requiredService !== undefined) {
|
||||
// Use parameterized LIKE with JSON array search - safe from injection
|
||||
conditions.push("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
|
||||
const sanitizedService = JSON.stringify(filters.requiredService).slice(1, -1);
|
||||
params.push(sanitizedService);
|
||||
}
|
||||
|
||||
if (filters.targetAudience !== undefined) {
|
||||
// Use parameterized LIKE with JSON array search - safe from injection
|
||||
conditions.push("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
|
||||
const sanitizedAudience = JSON.stringify(filters.targetAudience).slice(1, -1);
|
||||
params.push(sanitizedAudience);
|
||||
}
|
||||
|
||||
// Build WHERE conditions using shared helper
|
||||
const { conditions, params } = this.buildMetadataFilterConditions(filters);
|
||||
|
||||
const query = `SELECT COUNT(*) as count FROM templates WHERE ${conditions.join(' AND ')}`;
|
||||
const result = this.db.prepare(query).get(...params) as { count: number };
|
||||
|
||||
|
||||
return result.count;
|
||||
}
|
||||
|
||||
|
||||
@@ -290,4 +290,86 @@ export interface McpToolResponse {
|
||||
message?: string;
|
||||
code?: string;
|
||||
details?: Record<string, unknown>;
|
||||
executionId?: string;
|
||||
workflowId?: string;
|
||||
}
|
||||
|
||||
// Execution Filtering Types
|
||||
export type ExecutionMode = 'preview' | 'summary' | 'filtered' | 'full';
|
||||
|
||||
export interface ExecutionPreview {
|
||||
totalNodes: number;
|
||||
executedNodes: number;
|
||||
estimatedSizeKB: number;
|
||||
nodes: Record<string, NodePreview>;
|
||||
}
|
||||
|
||||
export interface NodePreview {
|
||||
status: 'success' | 'error';
|
||||
itemCounts: {
|
||||
input: number;
|
||||
output: number;
|
||||
};
|
||||
dataStructure: Record<string, any>;
|
||||
estimatedSizeKB: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ExecutionRecommendation {
|
||||
canFetchFull: boolean;
|
||||
suggestedMode: ExecutionMode;
|
||||
suggestedItemsLimit?: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ExecutionFilterOptions {
|
||||
mode?: ExecutionMode;
|
||||
nodeNames?: string[];
|
||||
itemsLimit?: number;
|
||||
includeInputData?: boolean;
|
||||
fieldsToInclude?: string[];
|
||||
}
|
||||
|
||||
export interface FilteredExecutionResponse {
|
||||
id: string;
|
||||
workflowId: string;
|
||||
status: ExecutionStatus;
|
||||
mode: ExecutionMode;
|
||||
startedAt: string;
|
||||
stoppedAt?: string;
|
||||
duration?: number;
|
||||
finished: boolean;
|
||||
|
||||
// Preview-specific data
|
||||
preview?: ExecutionPreview;
|
||||
recommendation?: ExecutionRecommendation;
|
||||
|
||||
// Summary/Filtered data
|
||||
summary?: {
|
||||
totalNodes: number;
|
||||
executedNodes: number;
|
||||
totalItems: number;
|
||||
hasMoreData: boolean;
|
||||
};
|
||||
nodes?: Record<string, FilteredNodeData>;
|
||||
|
||||
// Error information
|
||||
error?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface FilteredNodeData {
|
||||
executionTime?: number;
|
||||
itemsInput: number;
|
||||
itemsOutput: number;
|
||||
status: 'success' | 'error';
|
||||
error?: string;
|
||||
data?: {
|
||||
input?: any[][];
|
||||
output?: any[][];
|
||||
metadata: {
|
||||
totalItems: number;
|
||||
itemsShown: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -95,6 +95,25 @@ export function handleN8nApiError(error: unknown): N8nApiError {
|
||||
return new N8nApiError('Unknown error occurred', undefined, 'UNKNOWN_ERROR', error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format execution error message with guidance to use n8n_get_execution
|
||||
* @param executionId - The execution ID from the failed execution
|
||||
* @param workflowId - Optional workflow ID
|
||||
* @returns Formatted error message with n8n_get_execution guidance
|
||||
*/
|
||||
export function formatExecutionError(executionId: string, workflowId?: string): string {
|
||||
const workflowPrefix = workflowId ? `Workflow ${workflowId} execution ` : 'Execution ';
|
||||
return `${workflowPrefix}${executionId} failed. Use n8n_get_execution({id: '${executionId}', mode: 'preview'}) to investigate the error.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error message when no execution ID is available
|
||||
* @returns Generic guidance to check executions
|
||||
*/
|
||||
export function formatNoExecutionError(): string {
|
||||
return "Workflow failed to execute. Use n8n_list_executions to find recent executions, then n8n_get_execution with mode='preview' to investigate.";
|
||||
}
|
||||
|
||||
// Utility to extract user-friendly error messages
|
||||
export function getUserFriendlyErrorMessage(error: N8nApiError): string {
|
||||
switch (error.code) {
|
||||
@@ -109,7 +128,9 @@ export function getUserFriendlyErrorMessage(error: N8nApiError): string {
|
||||
case 'NO_RESPONSE':
|
||||
return 'Unable to connect to n8n. Please check the server URL and ensure n8n is running.';
|
||||
case 'SERVER_ERROR':
|
||||
return 'n8n server error. Please try again later or contact support.';
|
||||
// For server errors, we should not show generic message
|
||||
// Callers should check for execution context and use formatExecutionError instead
|
||||
return error.message || 'n8n server error occurred';
|
||||
default:
|
||||
return error.message || 'An unexpected error occurred';
|
||||
}
|
||||
|
||||
234
src/utils/node-type-normalizer.ts
Normal file
234
src/utils/node-type-normalizer.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Universal Node Type Normalizer - FOR DATABASE OPERATIONS ONLY
|
||||
*
|
||||
* ⚠️ WARNING: Do NOT use before n8n API calls!
|
||||
*
|
||||
* This class converts node types to SHORT form (database format).
|
||||
* The n8n API requires FULL form (n8n-nodes-base.*).
|
||||
*
|
||||
* **Use this ONLY when:**
|
||||
* - Querying the node database
|
||||
* - Searching for node information
|
||||
* - Looking up node metadata
|
||||
*
|
||||
* **Do NOT use before:**
|
||||
* - Creating workflows (n8n_create_workflow)
|
||||
* - Updating workflows (n8n_update_workflow)
|
||||
* - Any n8n API calls
|
||||
*
|
||||
* **IMPORTANT:** The n8n-mcp database stores nodes in SHORT form:
|
||||
* - n8n-nodes-base → nodes-base
|
||||
* - @n8n/n8n-nodes-langchain → nodes-langchain
|
||||
*
|
||||
* But the n8n API requires FULL form:
|
||||
* - nodes-base → n8n-nodes-base
|
||||
* - nodes-langchain → @n8n/n8n-nodes-langchain
|
||||
*
|
||||
* @example Database Lookup (CORRECT usage)
|
||||
* const dbType = NodeTypeNormalizer.normalizeToFullForm('n8n-nodes-base.webhook')
|
||||
* // → 'nodes-base.webhook'
|
||||
* const node = await repository.getNode(dbType)
|
||||
*
|
||||
* @example API Call (INCORRECT - Do NOT do this!)
|
||||
* const workflow = { nodes: [{ type: 'n8n-nodes-base.webhook' }] }
|
||||
* const normalized = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow)
|
||||
* // ❌ WRONG! normalized has SHORT form, API needs FULL form
|
||||
* await client.createWorkflow(normalized) // FAILS!
|
||||
*
|
||||
* @example API Call (CORRECT)
|
||||
* const workflow = { nodes: [{ type: 'n8n-nodes-base.webhook' }] }
|
||||
* // ✅ Send as-is to API (FULL form required)
|
||||
* await client.createWorkflow(workflow) // WORKS!
|
||||
*/
|
||||
|
||||
export interface NodeTypeNormalizationResult {
|
||||
original: string;
|
||||
normalized: string;
|
||||
wasNormalized: boolean;
|
||||
package: 'base' | 'langchain' | 'community' | 'unknown';
|
||||
}
|
||||
|
||||
export class NodeTypeNormalizer {
|
||||
/**
|
||||
* Normalize node type to canonical SHORT form (database format)
|
||||
*
|
||||
* This is the PRIMARY method to use throughout the codebase.
|
||||
* It converts any node type variation to the SHORT form that the database uses.
|
||||
*
|
||||
* **NOTE:** Method name says "ToFullForm" for backward compatibility,
|
||||
* but actually normalizes TO SHORT form to match database storage.
|
||||
*
|
||||
* @param type - Node type in any format
|
||||
* @returns Normalized node type in short form (database format)
|
||||
*
|
||||
* @example
|
||||
* normalizeToFullForm('n8n-nodes-base.webhook')
|
||||
* // → 'nodes-base.webhook'
|
||||
*
|
||||
* @example
|
||||
* normalizeToFullForm('nodes-base.webhook')
|
||||
* // → 'nodes-base.webhook' (unchanged)
|
||||
*
|
||||
* @example
|
||||
* normalizeToFullForm('@n8n/n8n-nodes-langchain.agent')
|
||||
* // → 'nodes-langchain.agent'
|
||||
*/
|
||||
static normalizeToFullForm(type: string): string {
|
||||
if (!type || typeof type !== 'string') {
|
||||
return type;
|
||||
}
|
||||
|
||||
// Normalize full forms to short form (database format)
|
||||
if (type.startsWith('n8n-nodes-base.')) {
|
||||
return type.replace(/^n8n-nodes-base\./, 'nodes-base.');
|
||||
}
|
||||
if (type.startsWith('@n8n/n8n-nodes-langchain.')) {
|
||||
return type.replace(/^@n8n\/n8n-nodes-langchain\./, 'nodes-langchain.');
|
||||
}
|
||||
// Handle n8n-nodes-langchain without @n8n/ prefix
|
||||
if (type.startsWith('n8n-nodes-langchain.')) {
|
||||
return type.replace(/^n8n-nodes-langchain\./, 'nodes-langchain.');
|
||||
}
|
||||
|
||||
// Already in short form or community node - return unchanged
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize with detailed result including metadata
|
||||
*
|
||||
* Use this when you need to know if normalization occurred
|
||||
* or what package the node belongs to.
|
||||
*
|
||||
* @param type - Node type in any format
|
||||
* @returns Detailed normalization result
|
||||
*
|
||||
* @example
|
||||
* normalizeWithDetails('nodes-base.webhook')
|
||||
* // → {
|
||||
* // original: 'nodes-base.webhook',
|
||||
* // normalized: 'n8n-nodes-base.webhook',
|
||||
* // wasNormalized: true,
|
||||
* // package: 'base'
|
||||
* // }
|
||||
*/
|
||||
static normalizeWithDetails(type: string): NodeTypeNormalizationResult {
|
||||
const original = type;
|
||||
const normalized = this.normalizeToFullForm(type);
|
||||
|
||||
return {
|
||||
original,
|
||||
normalized,
|
||||
wasNormalized: original !== normalized,
|
||||
package: this.detectPackage(normalized)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect package type from node type
|
||||
*
|
||||
* @param type - Node type (in any form)
|
||||
* @returns Package identifier
|
||||
*/
|
||||
private static detectPackage(type: string): 'base' | 'langchain' | 'community' | 'unknown' {
|
||||
// Check both short and full forms
|
||||
if (type.startsWith('nodes-base.') || type.startsWith('n8n-nodes-base.')) return 'base';
|
||||
if (type.startsWith('nodes-langchain.') || type.startsWith('@n8n/n8n-nodes-langchain.') || type.startsWith('n8n-nodes-langchain.')) return 'langchain';
|
||||
if (type.includes('.')) return 'community';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch normalize multiple node types
|
||||
*
|
||||
* Use this when you need to normalize multiple types at once.
|
||||
*
|
||||
* @param types - Array of node types
|
||||
* @returns Map of original → normalized types
|
||||
*
|
||||
* @example
|
||||
* normalizeBatch(['nodes-base.webhook', 'nodes-base.set'])
|
||||
* // → Map {
|
||||
* // 'nodes-base.webhook' => 'n8n-nodes-base.webhook',
|
||||
* // 'nodes-base.set' => 'n8n-nodes-base.set'
|
||||
* // }
|
||||
*/
|
||||
static normalizeBatch(types: string[]): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
for (const type of types) {
|
||||
result.set(type, this.normalizeToFullForm(type));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize all node types in a workflow
|
||||
*
|
||||
* This is the key method for fixing workflows before validation.
|
||||
* It normalizes all node types in place while preserving all other
|
||||
* workflow properties.
|
||||
*
|
||||
* @param workflow - Workflow object with nodes array
|
||||
* @returns Workflow with normalized node types
|
||||
*
|
||||
* @example
|
||||
* const workflow = {
|
||||
* nodes: [
|
||||
* { type: 'nodes-base.webhook', id: '1', name: 'Webhook' },
|
||||
* { type: 'nodes-base.set', id: '2', name: 'Set' }
|
||||
* ],
|
||||
* connections: {}
|
||||
* };
|
||||
* const normalized = normalizeWorkflowNodeTypes(workflow);
|
||||
* // workflow.nodes[0].type → 'n8n-nodes-base.webhook'
|
||||
* // workflow.nodes[1].type → 'n8n-nodes-base.set'
|
||||
*/
|
||||
static normalizeWorkflowNodeTypes(workflow: any): any {
|
||||
if (!workflow?.nodes || !Array.isArray(workflow.nodes)) {
|
||||
return workflow;
|
||||
}
|
||||
|
||||
return {
|
||||
...workflow,
|
||||
nodes: workflow.nodes.map((node: any) => ({
|
||||
...node,
|
||||
type: this.normalizeToFullForm(node.type)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node type is in full form (needs normalization)
|
||||
*
|
||||
* @param type - Node type to check
|
||||
* @returns True if in full form (will be normalized to short)
|
||||
*/
|
||||
static isFullForm(type: string): boolean {
|
||||
if (!type || typeof type !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
type.startsWith('n8n-nodes-base.') ||
|
||||
type.startsWith('@n8n/n8n-nodes-langchain.') ||
|
||||
type.startsWith('n8n-nodes-langchain.')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node type is in short form (database format)
|
||||
*
|
||||
* @param type - Node type to check
|
||||
* @returns True if in short form (already in database format)
|
||||
*/
|
||||
static isShortForm(type: string): boolean {
|
||||
if (!type || typeof type !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
type.startsWith('nodes-base.') ||
|
||||
type.startsWith('nodes-langchain.')
|
||||
);
|
||||
}
|
||||
}
|
||||
484
tests/fixtures/template-configs.ts
vendored
Normal file
484
tests/fixtures/template-configs.ts
vendored
Normal file
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* Test fixtures for template node configurations
|
||||
* Used across unit and integration tests for P0-R3 feature
|
||||
*/
|
||||
|
||||
import * as zlib from 'zlib';
|
||||
|
||||
export interface TemplateConfigFixture {
|
||||
node_type: string;
|
||||
template_id: number;
|
||||
template_name: string;
|
||||
template_views: number;
|
||||
node_name: string;
|
||||
parameters_json: string;
|
||||
credentials_json: string | null;
|
||||
has_credentials: number;
|
||||
has_expressions: number;
|
||||
complexity: 'simple' | 'medium' | 'complex';
|
||||
use_cases: string;
|
||||
rank?: number;
|
||||
}
|
||||
|
||||
export interface WorkflowFixture {
|
||||
id: string;
|
||||
name: string;
|
||||
nodes: any[];
|
||||
connections: Record<string, any>;
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample node configurations for common use cases
|
||||
*/
|
||||
export const sampleConfigs: Record<string, TemplateConfigFixture> = {
|
||||
simpleWebhook: {
|
||||
node_type: 'n8n-nodes-base.webhook',
|
||||
template_id: 1,
|
||||
template_name: 'Simple Webhook Trigger',
|
||||
template_views: 5000,
|
||||
node_name: 'Webhook',
|
||||
parameters_json: JSON.stringify({
|
||||
httpMethod: 'POST',
|
||||
path: 'webhook',
|
||||
responseMode: 'lastNode',
|
||||
alwaysOutputData: true
|
||||
}),
|
||||
credentials_json: null,
|
||||
has_credentials: 0,
|
||||
has_expressions: 0,
|
||||
complexity: 'simple',
|
||||
use_cases: JSON.stringify(['webhook processing', 'trigger automation']),
|
||||
rank: 1
|
||||
},
|
||||
|
||||
webhookWithAuth: {
|
||||
node_type: 'n8n-nodes-base.webhook',
|
||||
template_id: 2,
|
||||
template_name: 'Authenticated Webhook',
|
||||
template_views: 3000,
|
||||
node_name: 'Webhook',
|
||||
parameters_json: JSON.stringify({
|
||||
httpMethod: 'POST',
|
||||
path: 'secure-webhook',
|
||||
responseMode: 'responseNode',
|
||||
authentication: 'headerAuth'
|
||||
}),
|
||||
credentials_json: JSON.stringify({
|
||||
httpHeaderAuth: {
|
||||
id: '1',
|
||||
name: 'Header Auth'
|
||||
}
|
||||
}),
|
||||
has_credentials: 1,
|
||||
has_expressions: 0,
|
||||
complexity: 'medium',
|
||||
use_cases: JSON.stringify(['secure webhook', 'authenticated triggers']),
|
||||
rank: 2
|
||||
},
|
||||
|
||||
httpRequestBasic: {
|
||||
node_type: 'n8n-nodes-base.httpRequest',
|
||||
template_id: 3,
|
||||
template_name: 'Basic HTTP GET Request',
|
||||
template_views: 10000,
|
||||
node_name: 'HTTP Request',
|
||||
parameters_json: JSON.stringify({
|
||||
url: 'https://api.example.com/data',
|
||||
method: 'GET',
|
||||
responseFormat: 'json',
|
||||
options: {
|
||||
timeout: 10000,
|
||||
redirect: {
|
||||
followRedirects: true
|
||||
}
|
||||
}
|
||||
}),
|
||||
credentials_json: null,
|
||||
has_credentials: 0,
|
||||
has_expressions: 0,
|
||||
complexity: 'simple',
|
||||
use_cases: JSON.stringify(['API calls', 'data fetching']),
|
||||
rank: 1
|
||||
},
|
||||
|
||||
httpRequestWithExpressions: {
|
||||
node_type: 'n8n-nodes-base.httpRequest',
|
||||
template_id: 4,
|
||||
template_name: 'Dynamic HTTP Request',
|
||||
template_views: 7500,
|
||||
node_name: 'HTTP Request',
|
||||
parameters_json: JSON.stringify({
|
||||
url: '={{ $json.apiUrl }}',
|
||||
method: 'POST',
|
||||
sendBody: true,
|
||||
bodyParameters: {
|
||||
values: [
|
||||
{
|
||||
name: 'userId',
|
||||
value: '={{ $json.userId }}'
|
||||
},
|
||||
{
|
||||
name: 'action',
|
||||
value: '={{ $json.action }}'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
timeout: '={{ $json.timeout || 10000 }}'
|
||||
}
|
||||
}),
|
||||
credentials_json: null,
|
||||
has_credentials: 0,
|
||||
has_expressions: 1,
|
||||
complexity: 'complex',
|
||||
use_cases: JSON.stringify(['dynamic API calls', 'expression-based routing']),
|
||||
rank: 2
|
||||
},
|
||||
|
||||
slackMessage: {
|
||||
node_type: 'n8n-nodes-base.slack',
|
||||
template_id: 5,
|
||||
template_name: 'Send Slack Message',
|
||||
template_views: 8000,
|
||||
node_name: 'Slack',
|
||||
parameters_json: JSON.stringify({
|
||||
resource: 'message',
|
||||
operation: 'post',
|
||||
channel: '#general',
|
||||
text: 'Hello from n8n!'
|
||||
}),
|
||||
credentials_json: JSON.stringify({
|
||||
slackApi: {
|
||||
id: '2',
|
||||
name: 'Slack API'
|
||||
}
|
||||
}),
|
||||
has_credentials: 1,
|
||||
has_expressions: 0,
|
||||
complexity: 'simple',
|
||||
use_cases: JSON.stringify(['notifications', 'team communication']),
|
||||
rank: 1
|
||||
},
|
||||
|
||||
codeNodeTransform: {
|
||||
node_type: 'n8n-nodes-base.code',
|
||||
template_id: 6,
|
||||
template_name: 'Data Transformation',
|
||||
template_views: 6000,
|
||||
node_name: 'Code',
|
||||
parameters_json: JSON.stringify({
|
||||
mode: 'runOnceForAllItems',
|
||||
jsCode: `const items = $input.all();
|
||||
|
||||
return items.map(item => ({
|
||||
json: {
|
||||
id: item.json.id,
|
||||
name: item.json.name.toUpperCase(),
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}));`
|
||||
}),
|
||||
credentials_json: null,
|
||||
has_credentials: 0,
|
||||
has_expressions: 0,
|
||||
complexity: 'medium',
|
||||
use_cases: JSON.stringify(['data transformation', 'custom logic']),
|
||||
rank: 1
|
||||
},
|
||||
|
||||
codeNodeWithExpressions: {
|
||||
node_type: 'n8n-nodes-base.code',
|
||||
template_id: 7,
|
||||
template_name: 'Advanced Code with Expressions',
|
||||
template_views: 4500,
|
||||
node_name: 'Code',
|
||||
parameters_json: JSON.stringify({
|
||||
mode: 'runOnceForEachItem',
|
||||
jsCode: `const data = $input.item.json;
|
||||
const previousNode = $('HTTP Request').first().json;
|
||||
|
||||
return {
|
||||
json: {
|
||||
combined: data.value + previousNode.value,
|
||||
nodeRef: $node
|
||||
}
|
||||
};`
|
||||
}),
|
||||
credentials_json: null,
|
||||
has_credentials: 0,
|
||||
has_expressions: 1,
|
||||
complexity: 'complex',
|
||||
use_cases: JSON.stringify(['advanced transformations', 'node references']),
|
||||
rank: 2
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sample workflows for testing extraction
|
||||
*/
|
||||
export const sampleWorkflows: Record<string, WorkflowFixture> = {
|
||||
webhookToSlack: {
|
||||
id: '1',
|
||||
name: 'Webhook to Slack Notification',
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'POST',
|
||||
path: 'alert',
|
||||
responseMode: 'lastNode'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'slack1',
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 1,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
resource: 'message',
|
||||
operation: 'post',
|
||||
channel: '#alerts',
|
||||
text: '={{ $json.message }}'
|
||||
},
|
||||
credentials: {
|
||||
slackApi: {
|
||||
id: '1',
|
||||
name: 'Slack API'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
webhook1: {
|
||||
main: [[{ node: 'slack1', type: 'main', index: 0 }]]
|
||||
}
|
||||
},
|
||||
settings: {}
|
||||
},
|
||||
|
||||
apiWorkflow: {
|
||||
id: '2',
|
||||
name: 'API Data Processing',
|
||||
nodes: [
|
||||
{
|
||||
id: 'http1',
|
||||
name: 'Fetch Data',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
url: 'https://api.example.com/users',
|
||||
method: 'GET',
|
||||
responseFormat: 'json'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'code1',
|
||||
name: 'Transform',
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 2,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
mode: 'runOnceForAllItems',
|
||||
jsCode: 'return $input.all().map(item => ({ json: { ...item.json, processed: true } }));'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'http2',
|
||||
name: 'Send Results',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [650, 300],
|
||||
parameters: {
|
||||
url: '={{ $json.callbackUrl }}',
|
||||
method: 'POST',
|
||||
sendBody: true,
|
||||
bodyParameters: {
|
||||
values: [
|
||||
{ name: 'data', value: '={{ JSON.stringify($json) }}' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
http1: {
|
||||
main: [[{ node: 'code1', type: 'main', index: 0 }]]
|
||||
},
|
||||
code1: {
|
||||
main: [[{ node: 'http2', type: 'main', index: 0 }]]
|
||||
}
|
||||
},
|
||||
settings: {}
|
||||
},
|
||||
|
||||
complexWorkflow: {
|
||||
id: '3',
|
||||
name: 'Complex Multi-Node Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook1',
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [100, 300],
|
||||
parameters: {
|
||||
httpMethod: 'POST',
|
||||
path: 'start'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'sticky1',
|
||||
name: 'Note',
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
typeVersion: 1,
|
||||
position: [100, 200],
|
||||
parameters: {
|
||||
content: 'This workflow processes incoming data'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'if1',
|
||||
name: 'Check Type',
|
||||
type: 'n8n-nodes-base.if',
|
||||
typeVersion: 1,
|
||||
position: [300, 300],
|
||||
parameters: {
|
||||
conditions: {
|
||||
boolean: [
|
||||
{
|
||||
value1: '={{ $json.type }}',
|
||||
value2: 'premium'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'http1',
|
||||
name: 'Premium API',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [500, 200],
|
||||
parameters: {
|
||||
url: 'https://api.example.com/premium',
|
||||
method: 'POST'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'http2',
|
||||
name: 'Standard API',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [500, 400],
|
||||
parameters: {
|
||||
url: 'https://api.example.com/standard',
|
||||
method: 'POST'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
webhook1: {
|
||||
main: [[{ node: 'if1', type: 'main', index: 0 }]]
|
||||
},
|
||||
if1: {
|
||||
main: [
|
||||
[{ node: 'http1', type: 'main', index: 0 }],
|
||||
[{ node: 'http2', type: 'main', index: 0 }]
|
||||
]
|
||||
}
|
||||
},
|
||||
settings: {}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Compress workflow to base64 (mimics n8n template format)
|
||||
*/
|
||||
export function compressWorkflow(workflow: WorkflowFixture): string {
|
||||
const json = JSON.stringify(workflow);
|
||||
return zlib.gzipSync(Buffer.from(json, 'utf-8')).toString('base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create template metadata
|
||||
*/
|
||||
export function createTemplateMetadata(complexity: 'simple' | 'medium' | 'complex', useCases: string[]) {
|
||||
return {
|
||||
complexity,
|
||||
use_cases: useCases
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch create configs for testing
|
||||
*/
|
||||
export function createConfigBatch(nodeType: string, count: number): TemplateConfigFixture[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
node_type: nodeType,
|
||||
template_id: i + 1,
|
||||
template_name: `Template ${i + 1}`,
|
||||
template_views: 1000 - (i * 50),
|
||||
node_name: `Node ${i + 1}`,
|
||||
parameters_json: JSON.stringify({ index: i }),
|
||||
credentials_json: null,
|
||||
has_credentials: 0,
|
||||
has_expressions: 0,
|
||||
complexity: (['simple', 'medium', 'complex'] as const)[i % 3],
|
||||
use_cases: JSON.stringify(['test use case']),
|
||||
rank: i + 1
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get config by complexity
|
||||
*/
|
||||
export function getConfigByComplexity(complexity: 'simple' | 'medium' | 'complex'): TemplateConfigFixture {
|
||||
const configs = Object.values(sampleConfigs);
|
||||
const match = configs.find(c => c.complexity === complexity);
|
||||
return match || configs[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configs with expressions
|
||||
*/
|
||||
export function getConfigsWithExpressions(): TemplateConfigFixture[] {
|
||||
return Object.values(sampleConfigs).filter(c => c.has_expressions === 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configs with credentials
|
||||
*/
|
||||
export function getConfigsWithCredentials(): TemplateConfigFixture[] {
|
||||
return Object.values(sampleConfigs).filter(c => c.has_credentials === 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock database insert helper
|
||||
*/
|
||||
export function createInsertStatement(config: TemplateConfigFixture): string {
|
||||
return `INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, credentials_json,
|
||||
has_credentials, has_expressions, complexity, use_cases, rank
|
||||
) VALUES (
|
||||
'${config.node_type}',
|
||||
${config.template_id},
|
||||
'${config.template_name}',
|
||||
${config.template_views},
|
||||
'${config.node_name}',
|
||||
'${config.parameters_json.replace(/'/g, "''")}',
|
||||
${config.credentials_json ? `'${config.credentials_json.replace(/'/g, "''")}'` : 'NULL'},
|
||||
${config.has_credentials},
|
||||
${config.has_expressions},
|
||||
'${config.complexity}',
|
||||
'${config.use_cases.replace(/'/g, "''")}',
|
||||
${config.rank || 0}
|
||||
)`;
|
||||
}
|
||||
534
tests/integration/database/template-node-configs.test.ts
Normal file
534
tests/integration/database/template-node-configs.test.ts
Normal file
@@ -0,0 +1,534 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { DatabaseAdapter, createDatabaseAdapter } from '../../../src/database/database-adapter';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Integration tests for template_node_configs table
|
||||
* Testing database schema, migrations, and data operations
|
||||
*/
|
||||
|
||||
describe('Template Node Configs Database Integration', () => {
|
||||
let db: DatabaseAdapter;
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temporary database
|
||||
dbPath = ':memory:';
|
||||
db = await createDatabaseAdapter(dbPath);
|
||||
|
||||
// Apply schema
|
||||
const schemaPath = path.join(__dirname, '../../../src/database/schema.sql');
|
||||
const schema = fs.readFileSync(schemaPath, 'utf-8');
|
||||
db.exec(schema);
|
||||
|
||||
// Apply migration
|
||||
const migrationPath = path.join(__dirname, '../../../src/database/migrations/add-template-node-configs.sql');
|
||||
const migration = fs.readFileSync(migrationPath, 'utf-8');
|
||||
db.exec(migration);
|
||||
|
||||
// Insert test templates with id 1-1000 to satisfy foreign key constraints
|
||||
// Tests insert configs with various template_id values, so we pre-create many templates
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO templates (
|
||||
id, workflow_id, name, description, views,
|
||||
nodes_used, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
`);
|
||||
for (let i = 1; i <= 1000; i++) {
|
||||
stmt.run(i, i, `Test Template ${i}`, 'Test template for node configs', 100, '[]');
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if ('close' in db && typeof db.close === 'function') {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe('Schema Validation', () => {
|
||||
it('should create template_node_configs table', () => {
|
||||
const tableExists = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='template_node_configs'
|
||||
`).get();
|
||||
|
||||
expect(tableExists).toBeDefined();
|
||||
expect(tableExists).toHaveProperty('name', 'template_node_configs');
|
||||
});
|
||||
|
||||
it('should have all required columns', () => {
|
||||
const columns = db.prepare(`PRAGMA table_info(template_node_configs)`).all() as any[];
|
||||
|
||||
const columnNames = columns.map(col => col.name);
|
||||
expect(columnNames).toContain('id');
|
||||
expect(columnNames).toContain('node_type');
|
||||
expect(columnNames).toContain('template_id');
|
||||
expect(columnNames).toContain('template_name');
|
||||
expect(columnNames).toContain('template_views');
|
||||
expect(columnNames).toContain('node_name');
|
||||
expect(columnNames).toContain('parameters_json');
|
||||
expect(columnNames).toContain('credentials_json');
|
||||
expect(columnNames).toContain('has_credentials');
|
||||
expect(columnNames).toContain('has_expressions');
|
||||
expect(columnNames).toContain('complexity');
|
||||
expect(columnNames).toContain('use_cases');
|
||||
expect(columnNames).toContain('rank');
|
||||
expect(columnNames).toContain('created_at');
|
||||
});
|
||||
|
||||
it('should have correct column types and constraints', () => {
|
||||
const columns = db.prepare(`PRAGMA table_info(template_node_configs)`).all() as any[];
|
||||
|
||||
const idColumn = columns.find(col => col.name === 'id');
|
||||
expect(idColumn.pk).toBe(1); // Primary key
|
||||
|
||||
const nodeTypeColumn = columns.find(col => col.name === 'node_type');
|
||||
expect(nodeTypeColumn.notnull).toBe(1); // NOT NULL
|
||||
|
||||
const parametersJsonColumn = columns.find(col => col.name === 'parameters_json');
|
||||
expect(parametersJsonColumn.notnull).toBe(1); // NOT NULL
|
||||
});
|
||||
|
||||
it('should have complexity CHECK constraint', () => {
|
||||
// Try to insert invalid complexity
|
||||
expect(() => {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, complexity
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
1,
|
||||
'Test Template',
|
||||
100,
|
||||
'Test Node',
|
||||
'{}',
|
||||
'invalid' // Should fail CHECK constraint
|
||||
);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('should accept valid complexity values', () => {
|
||||
const validComplexities = ['simple', 'medium', 'complex'];
|
||||
|
||||
validComplexities.forEach((complexity, index) => {
|
||||
expect(() => {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, complexity
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
index + 1,
|
||||
'Test Template',
|
||||
100,
|
||||
'Test Node',
|
||||
'{}',
|
||||
complexity
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
const count = db.prepare('SELECT COUNT(*) as count FROM template_node_configs').get() as any;
|
||||
expect(count.count).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Indexes', () => {
|
||||
it('should create idx_config_node_type_rank index', () => {
|
||||
const indexes = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='index' AND tbl_name='template_node_configs'
|
||||
`).all() as any[];
|
||||
|
||||
const indexNames = indexes.map(idx => idx.name);
|
||||
expect(indexNames).toContain('idx_config_node_type_rank');
|
||||
});
|
||||
|
||||
it('should create idx_config_complexity index', () => {
|
||||
const indexes = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='index' AND tbl_name='template_node_configs'
|
||||
`).all() as any[];
|
||||
|
||||
const indexNames = indexes.map(idx => idx.name);
|
||||
expect(indexNames).toContain('idx_config_complexity');
|
||||
});
|
||||
|
||||
it('should create idx_config_auth index', () => {
|
||||
const indexes = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='index' AND tbl_name='template_node_configs'
|
||||
`).all() as any[];
|
||||
|
||||
const indexNames = indexes.map(idx => idx.name);
|
||||
expect(indexNames).toContain('idx_config_auth');
|
||||
});
|
||||
});
|
||||
|
||||
describe('View: ranked_node_configs', () => {
|
||||
it('should create ranked_node_configs view', () => {
|
||||
const viewExists = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='view' AND name='ranked_node_configs'
|
||||
`).get();
|
||||
|
||||
expect(viewExists).toBeDefined();
|
||||
expect(viewExists).toHaveProperty('name', 'ranked_node_configs');
|
||||
});
|
||||
|
||||
it('should return only top 5 ranked configs per node type', () => {
|
||||
// Insert 10 configs for same node type with different ranks
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.httpRequest',
|
||||
i,
|
||||
`Template ${i}`,
|
||||
1000 - (i * 50), // Decreasing views
|
||||
'HTTP Request',
|
||||
'{}',
|
||||
i // Rank 1-10
|
||||
);
|
||||
}
|
||||
|
||||
const rankedConfigs = db.prepare('SELECT * FROM ranked_node_configs').all() as any[];
|
||||
|
||||
// Should only return rank 1-5
|
||||
expect(rankedConfigs).toHaveLength(5);
|
||||
expect(Math.max(...rankedConfigs.map(c => c.rank))).toBe(5);
|
||||
expect(Math.min(...rankedConfigs.map(c => c.rank))).toBe(1);
|
||||
});
|
||||
|
||||
it('should order by node_type and rank', () => {
|
||||
// Insert configs for multiple node types
|
||||
const configs = [
|
||||
{ nodeType: 'n8n-nodes-base.webhook', rank: 2 },
|
||||
{ nodeType: 'n8n-nodes-base.webhook', rank: 1 },
|
||||
{ nodeType: 'n8n-nodes-base.httpRequest', rank: 2 },
|
||||
{ nodeType: 'n8n-nodes-base.httpRequest', rank: 1 },
|
||||
];
|
||||
|
||||
configs.forEach((config, index) => {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
config.nodeType,
|
||||
index + 1,
|
||||
`Template ${index}`,
|
||||
100,
|
||||
'Node',
|
||||
'{}',
|
||||
config.rank
|
||||
);
|
||||
});
|
||||
|
||||
const rankedConfigs = db.prepare('SELECT * FROM ranked_node_configs ORDER BY node_type, rank').all() as any[];
|
||||
|
||||
// First two should be httpRequest rank 1, 2
|
||||
expect(rankedConfigs[0].node_type).toBe('n8n-nodes-base.httpRequest');
|
||||
expect(rankedConfigs[0].rank).toBe(1);
|
||||
expect(rankedConfigs[1].node_type).toBe('n8n-nodes-base.httpRequest');
|
||||
expect(rankedConfigs[1].rank).toBe(2);
|
||||
|
||||
// Last two should be webhook rank 1, 2
|
||||
expect(rankedConfigs[2].node_type).toBe('n8n-nodes-base.webhook');
|
||||
expect(rankedConfigs[2].rank).toBe(1);
|
||||
expect(rankedConfigs[3].node_type).toBe('n8n-nodes-base.webhook');
|
||||
expect(rankedConfigs[3].rank).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Foreign Key Constraints', () => {
|
||||
beforeEach(() => {
|
||||
// Enable foreign keys
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
// Note: Templates are already created in the main beforeEach
|
||||
});
|
||||
|
||||
it('should allow inserting config with valid template_id', () => {
|
||||
expect(() => {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
1, // Valid template_id
|
||||
'Test Template',
|
||||
100,
|
||||
'Test Node',
|
||||
'{}'
|
||||
);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should cascade delete configs when template is deleted', () => {
|
||||
// Insert config
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
1,
|
||||
'Test Template',
|
||||
100,
|
||||
'Test Node',
|
||||
'{}'
|
||||
);
|
||||
|
||||
// Verify config exists
|
||||
let configs = db.prepare('SELECT * FROM template_node_configs WHERE template_id = ?').all(1) as any[];
|
||||
expect(configs).toHaveLength(1);
|
||||
|
||||
// Delete template
|
||||
db.prepare('DELETE FROM templates WHERE id = ?').run(1);
|
||||
|
||||
// Verify config is deleted (CASCADE)
|
||||
configs = db.prepare('SELECT * FROM template_node_configs WHERE template_id = ?').all(1) as any[];
|
||||
expect(configs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Operations', () => {
|
||||
it('should insert and retrieve config with all fields', () => {
|
||||
const testConfig = {
|
||||
node_type: 'n8n-nodes-base.webhook',
|
||||
template_id: 1,
|
||||
template_name: 'Webhook Template',
|
||||
template_views: 2000,
|
||||
node_name: 'Webhook Trigger',
|
||||
parameters_json: JSON.stringify({
|
||||
httpMethod: 'POST',
|
||||
path: 'webhook-test',
|
||||
responseMode: 'lastNode'
|
||||
}),
|
||||
credentials_json: JSON.stringify({
|
||||
webhookAuth: { id: '1', name: 'Webhook Auth' }
|
||||
}),
|
||||
has_credentials: 1,
|
||||
has_expressions: 1,
|
||||
complexity: 'medium',
|
||||
use_cases: JSON.stringify(['webhook processing', 'automation triggers']),
|
||||
rank: 1
|
||||
};
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, credentials_json,
|
||||
has_credentials, has_expressions, complexity, use_cases, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(...Object.values(testConfig));
|
||||
|
||||
const retrieved = db.prepare('SELECT * FROM template_node_configs WHERE id = 1').get() as any;
|
||||
|
||||
expect(retrieved.node_type).toBe(testConfig.node_type);
|
||||
expect(retrieved.template_id).toBe(testConfig.template_id);
|
||||
expect(retrieved.template_name).toBe(testConfig.template_name);
|
||||
expect(retrieved.template_views).toBe(testConfig.template_views);
|
||||
expect(retrieved.node_name).toBe(testConfig.node_name);
|
||||
expect(retrieved.parameters_json).toBe(testConfig.parameters_json);
|
||||
expect(retrieved.credentials_json).toBe(testConfig.credentials_json);
|
||||
expect(retrieved.has_credentials).toBe(testConfig.has_credentials);
|
||||
expect(retrieved.has_expressions).toBe(testConfig.has_expressions);
|
||||
expect(retrieved.complexity).toBe(testConfig.complexity);
|
||||
expect(retrieved.use_cases).toBe(testConfig.use_cases);
|
||||
expect(retrieved.rank).toBe(testConfig.rank);
|
||||
expect(retrieved.created_at).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle nullable fields correctly', () => {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
1,
|
||||
'Test',
|
||||
100,
|
||||
'Node',
|
||||
'{}'
|
||||
);
|
||||
|
||||
const retrieved = db.prepare('SELECT * FROM template_node_configs WHERE id = 1').get() as any;
|
||||
|
||||
expect(retrieved.credentials_json).toBeNull();
|
||||
expect(retrieved.has_credentials).toBe(0); // Default value
|
||||
expect(retrieved.has_expressions).toBe(0); // Default value
|
||||
expect(retrieved.rank).toBe(0); // Default value
|
||||
});
|
||||
|
||||
it('should update rank values', () => {
|
||||
// Insert multiple configs
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
i,
|
||||
'Template',
|
||||
100,
|
||||
'Node',
|
||||
'{}',
|
||||
0 // Initial rank
|
||||
);
|
||||
}
|
||||
|
||||
// Update ranks
|
||||
db.exec(`
|
||||
UPDATE template_node_configs
|
||||
SET rank = (
|
||||
SELECT COUNT(*) + 1
|
||||
FROM template_node_configs AS t2
|
||||
WHERE t2.node_type = template_node_configs.node_type
|
||||
AND t2.template_views > template_node_configs.template_views
|
||||
)
|
||||
`);
|
||||
|
||||
const configs = db.prepare('SELECT * FROM template_node_configs ORDER BY rank').all() as any[];
|
||||
|
||||
// All should have same rank (same views)
|
||||
expect(configs.every(c => c.rank === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should delete configs with rank > 10', () => {
|
||||
// Insert 15 configs with different ranks
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
i,
|
||||
'Template',
|
||||
100,
|
||||
'Node',
|
||||
'{}',
|
||||
i // Rank 1-15
|
||||
);
|
||||
}
|
||||
|
||||
// Delete configs with rank > 10
|
||||
db.exec(`
|
||||
DELETE FROM template_node_configs
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM template_node_configs
|
||||
WHERE rank <= 10
|
||||
ORDER BY node_type, rank
|
||||
)
|
||||
`);
|
||||
|
||||
const remaining = db.prepare('SELECT * FROM template_node_configs').all() as any[];
|
||||
|
||||
expect(remaining).toHaveLength(10);
|
||||
expect(Math.max(...remaining.map(c => c.rank))).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Query Performance', () => {
|
||||
beforeEach(() => {
|
||||
// Insert 1000 configs for performance testing
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const nodeTypes = [
|
||||
'n8n-nodes-base.httpRequest',
|
||||
'n8n-nodes-base.webhook',
|
||||
'n8n-nodes-base.slack',
|
||||
'n8n-nodes-base.googleSheets',
|
||||
'n8n-nodes-base.code'
|
||||
];
|
||||
|
||||
for (let i = 1; i <= 1000; i++) {
|
||||
const nodeType = nodeTypes[i % nodeTypes.length];
|
||||
stmt.run(
|
||||
nodeType,
|
||||
i,
|
||||
`Template ${i}`,
|
||||
Math.floor(Math.random() * 10000),
|
||||
'Node',
|
||||
'{}',
|
||||
(i % 10) + 1 // Rank 1-10
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should query by node_type and rank efficiently', () => {
|
||||
const start = Date.now();
|
||||
const results = db.prepare(`
|
||||
SELECT * FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
ORDER BY rank
|
||||
LIMIT 3
|
||||
`).all('n8n-nodes-base.httpRequest') as any[];
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(duration).toBeLessThan(10); // Should be very fast with index
|
||||
});
|
||||
|
||||
it('should filter by complexity efficiently', () => {
|
||||
// First set some complexity values
|
||||
db.exec(`UPDATE template_node_configs SET complexity = 'simple' WHERE id % 3 = 0`);
|
||||
db.exec(`UPDATE template_node_configs SET complexity = 'medium' WHERE id % 3 = 1`);
|
||||
db.exec(`UPDATE template_node_configs SET complexity = 'complex' WHERE id % 3 = 2`);
|
||||
|
||||
const start = Date.now();
|
||||
const results = db.prepare(`
|
||||
SELECT * FROM template_node_configs
|
||||
WHERE node_type = ? AND complexity = ?
|
||||
ORDER BY rank
|
||||
LIMIT 5
|
||||
`).all('n8n-nodes-base.webhook', 'simple') as any[];
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(duration).toBeLessThan(10); // Should be fast with index
|
||||
});
|
||||
});
|
||||
|
||||
describe('Migration Idempotency', () => {
|
||||
it('should be safe to run migration multiple times', () => {
|
||||
const migrationPath = path.join(__dirname, '../../../src/database/migrations/add-template-node-configs.sql');
|
||||
const migration = fs.readFileSync(migrationPath, 'utf-8');
|
||||
|
||||
// Run migration again
|
||||
expect(() => {
|
||||
db.exec(migration);
|
||||
}).not.toThrow();
|
||||
|
||||
// Table should still exist
|
||||
const tableExists = db.prepare(`
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='table' AND name='template_node_configs'
|
||||
`).get();
|
||||
|
||||
expect(tableExists).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -643,6 +643,207 @@ describe('TemplateRepository Integration Tests', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchTemplatesByMetadata - Two-Phase Optimization', () => {
|
||||
it('should use two-phase query pattern for performance', () => {
|
||||
// Setup: Create templates with metadata and different views for deterministic ordering
|
||||
const templates = [
|
||||
{ id: 1, complexity: 'simple', category: 'automation', views: 200 },
|
||||
{ id: 2, complexity: 'medium', category: 'integration', views: 300 },
|
||||
{ id: 3, complexity: 'simple', category: 'automation', views: 100 },
|
||||
{ id: 4, complexity: 'complex', category: 'data-processing', views: 400 }
|
||||
];
|
||||
|
||||
templates.forEach(({ id, complexity, category, views }) => {
|
||||
const template = createTemplateWorkflow({ id, name: `Template ${id}`, totalViews: views });
|
||||
const detail = createTemplateDetail({
|
||||
id,
|
||||
views,
|
||||
workflow: {
|
||||
id: id.toString(),
|
||||
name: `Template ${id}`,
|
||||
nodes: [],
|
||||
connections: {},
|
||||
settings: {}
|
||||
}
|
||||
});
|
||||
|
||||
repository.saveTemplate(template, detail);
|
||||
|
||||
// Update views to match our test data
|
||||
db.prepare(`UPDATE templates SET views = ? WHERE workflow_id = ?`).run(views, id);
|
||||
|
||||
// Add metadata
|
||||
const metadata = {
|
||||
categories: [category],
|
||||
complexity,
|
||||
use_cases: ['test'],
|
||||
estimated_setup_minutes: 15,
|
||||
required_services: [],
|
||||
key_features: ['test'],
|
||||
target_audience: ['developers']
|
||||
};
|
||||
|
||||
db.prepare(`
|
||||
UPDATE templates
|
||||
SET metadata_json = ?,
|
||||
metadata_generated_at = datetime('now')
|
||||
WHERE workflow_id = ?
|
||||
`).run(JSON.stringify(metadata), id);
|
||||
});
|
||||
|
||||
// Test: Search with filter should return matching templates
|
||||
const results = repository.searchTemplatesByMetadata({ complexity: 'simple' }, 10, 0);
|
||||
|
||||
// Verify results - Ordered by views DESC (200, 100), then created_at DESC, then id ASC
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].workflow_id).toBe(1); // 200 views
|
||||
expect(results[1].workflow_id).toBe(3); // 100 views
|
||||
});
|
||||
|
||||
it('should preserve exact ordering from Phase 1', () => {
|
||||
// Setup: Create templates with different view counts
|
||||
// Use unique views to ensure deterministic ordering
|
||||
const templates = [
|
||||
{ id: 1, views: 100 },
|
||||
{ id: 2, views: 500 },
|
||||
{ id: 3, views: 300 },
|
||||
{ id: 4, views: 400 },
|
||||
{ id: 5, views: 200 }
|
||||
];
|
||||
|
||||
templates.forEach(({ id, views }) => {
|
||||
const template = createTemplateWorkflow({ id, name: `Template ${id}`, totalViews: views });
|
||||
const detail = createTemplateDetail({
|
||||
id,
|
||||
views,
|
||||
workflow: {
|
||||
id: id.toString(),
|
||||
name: `Template ${id}`,
|
||||
nodes: [],
|
||||
connections: {},
|
||||
settings: {}
|
||||
}
|
||||
});
|
||||
|
||||
repository.saveTemplate(template, detail);
|
||||
|
||||
// Update views in database to match our test data
|
||||
db.prepare(`UPDATE templates SET views = ? WHERE workflow_id = ?`).run(views, id);
|
||||
|
||||
// Add metadata
|
||||
const metadata = {
|
||||
categories: ['test'],
|
||||
complexity: 'medium',
|
||||
use_cases: ['test'],
|
||||
estimated_setup_minutes: 15,
|
||||
required_services: [],
|
||||
key_features: ['test'],
|
||||
target_audience: ['developers']
|
||||
};
|
||||
|
||||
db.prepare(`
|
||||
UPDATE templates
|
||||
SET metadata_json = ?,
|
||||
metadata_generated_at = datetime('now')
|
||||
WHERE workflow_id = ?
|
||||
`).run(JSON.stringify(metadata), id);
|
||||
});
|
||||
|
||||
// Test: Search should return templates in correct order
|
||||
const results = repository.searchTemplatesByMetadata({ complexity: 'medium' }, 10, 0);
|
||||
|
||||
// Verify ordering: 500 views, 400 views, 300 views, 200 views, 100 views
|
||||
expect(results).toHaveLength(5);
|
||||
expect(results[0].workflow_id).toBe(2); // 500 views
|
||||
expect(results[1].workflow_id).toBe(4); // 400 views
|
||||
expect(results[2].workflow_id).toBe(3); // 300 views
|
||||
expect(results[3].workflow_id).toBe(5); // 200 views
|
||||
expect(results[4].workflow_id).toBe(1); // 100 views
|
||||
});
|
||||
|
||||
it('should handle empty results efficiently', () => {
|
||||
// Setup: Create templates without the searched complexity
|
||||
const template = createTemplateWorkflow({ id: 1 });
|
||||
const detail = createTemplateDetail({
|
||||
id: 1,
|
||||
workflow: {
|
||||
id: '1',
|
||||
name: 'Template 1',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
settings: {}
|
||||
}
|
||||
});
|
||||
|
||||
repository.saveTemplate(template, detail);
|
||||
|
||||
const metadata = {
|
||||
categories: ['test'],
|
||||
complexity: 'simple',
|
||||
use_cases: ['test'],
|
||||
estimated_setup_minutes: 15,
|
||||
required_services: [],
|
||||
key_features: ['test'],
|
||||
target_audience: ['developers']
|
||||
};
|
||||
|
||||
db.prepare(`
|
||||
UPDATE templates
|
||||
SET metadata_json = ?,
|
||||
metadata_generated_at = datetime('now')
|
||||
WHERE workflow_id = 1
|
||||
`).run(JSON.stringify(metadata));
|
||||
|
||||
// Test: Search for non-existent complexity
|
||||
const results = repository.searchTemplatesByMetadata({ complexity: 'complex' }, 10, 0);
|
||||
|
||||
// Verify: Should return empty array without errors
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should validate IDs defensively', () => {
|
||||
// This test ensures the defensive ID validation works
|
||||
// Setup: Create a template
|
||||
const template = createTemplateWorkflow({ id: 1 });
|
||||
const detail = createTemplateDetail({
|
||||
id: 1,
|
||||
workflow: {
|
||||
id: '1',
|
||||
name: 'Template 1',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
settings: {}
|
||||
}
|
||||
});
|
||||
|
||||
repository.saveTemplate(template, detail);
|
||||
|
||||
const metadata = {
|
||||
categories: ['test'],
|
||||
complexity: 'simple',
|
||||
use_cases: ['test'],
|
||||
estimated_setup_minutes: 15,
|
||||
required_services: [],
|
||||
key_features: ['test'],
|
||||
target_audience: ['developers']
|
||||
};
|
||||
|
||||
db.prepare(`
|
||||
UPDATE templates
|
||||
SET metadata_json = ?,
|
||||
metadata_generated_at = datetime('now')
|
||||
WHERE workflow_id = 1
|
||||
`).run(JSON.stringify(metadata));
|
||||
|
||||
// Test: Normal search should work
|
||||
const results = repository.searchTemplatesByMetadata({ complexity: 'simple' }, 10, 0);
|
||||
|
||||
// Verify: Should return the template
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].workflow_id).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
|
||||
@@ -483,10 +483,11 @@ describe('MCP Session Management', { timeout: 15000 }, () => {
|
||||
await client.connect(clientTransport);
|
||||
|
||||
// Multiple error-inducing requests
|
||||
// Note: get_node_for_task was removed in v2.15.0
|
||||
const errorPromises = [
|
||||
client.callTool({ name: 'get_node_info', arguments: { nodeType: 'invalid1' } }).catch(e => e),
|
||||
client.callTool({ name: 'get_node_info', arguments: { nodeType: 'invalid2' } }).catch(e => e),
|
||||
client.callTool({ name: 'get_node_for_task', arguments: { task: 'invalid_task' } }).catch(e => e)
|
||||
client.callTool({ name: 'search_nodes', arguments: { query: '' } }).catch(e => e) // Empty query should error
|
||||
];
|
||||
|
||||
const errors = await Promise.all(errorPromises);
|
||||
|
||||
@@ -459,30 +459,8 @@ describe('MCP Tool Invocation', () => {
|
||||
});
|
||||
|
||||
describe('Task Templates', () => {
|
||||
describe('get_node_for_task', () => {
|
||||
it('should return pre-configured node for task', async () => {
|
||||
const response = await client.callTool({ name: 'get_node_for_task', arguments: {
|
||||
task: 'post_json_request'
|
||||
}});
|
||||
|
||||
const config = JSON.parse(((response as any).content[0]).text);
|
||||
expect(config).toHaveProperty('task');
|
||||
expect(config).toHaveProperty('nodeType');
|
||||
expect(config).toHaveProperty('configuration');
|
||||
expect(config.configuration.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('should handle unknown tasks', async () => {
|
||||
try {
|
||||
await client.callTool({ name: 'get_node_for_task', arguments: {
|
||||
task: 'unknown_task'
|
||||
}});
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (error: any) {
|
||||
expect(error.message).toContain('Unknown task');
|
||||
}
|
||||
});
|
||||
});
|
||||
// get_node_for_task was removed in v2.15.0
|
||||
// Use search_nodes({ includeExamples: true }) instead for real-world examples
|
||||
|
||||
describe('list_tasks', () => {
|
||||
it('should list all available tasks', async () => {
|
||||
|
||||
452
tests/integration/mcp/template-examples-e2e.test.ts
Normal file
452
tests/integration/mcp/template-examples-e2e.test.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createDatabaseAdapter, DatabaseAdapter } from '../../../src/database/database-adapter';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { sampleConfigs, compressWorkflow, sampleWorkflows } from '../../fixtures/template-configs';
|
||||
|
||||
/**
|
||||
* End-to-end integration tests for template-based examples feature
|
||||
* Tests the complete flow: database -> MCP server -> examples in response
|
||||
*/
|
||||
|
||||
describe('Template Examples E2E Integration', () => {
|
||||
let db: DatabaseAdapter;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create in-memory database
|
||||
db = await createDatabaseAdapter(':memory:');
|
||||
|
||||
// Apply schema
|
||||
const schemaPath = path.join(__dirname, '../../../src/database/schema.sql');
|
||||
const schema = fs.readFileSync(schemaPath, 'utf-8');
|
||||
db.exec(schema);
|
||||
|
||||
// Apply migration
|
||||
const migrationPath = path.join(__dirname, '../../../src/database/migrations/add-template-node-configs.sql');
|
||||
const migration = fs.readFileSync(migrationPath, 'utf-8');
|
||||
db.exec(migration);
|
||||
|
||||
// Seed test data
|
||||
seedTemplateConfigs();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if ('close' in db && typeof db.close === 'function') {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
function seedTemplateConfigs() {
|
||||
// Insert sample templates first to satisfy foreign key constraints
|
||||
// The sampleConfigs use template_id 1-4, edge cases use 998-999
|
||||
const templateIds = [1, 2, 3, 4, 998, 999];
|
||||
for (const id of templateIds) {
|
||||
db.prepare(`
|
||||
INSERT INTO templates (
|
||||
id, workflow_id, name, description, views,
|
||||
nodes_used, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
`).run(
|
||||
id,
|
||||
id,
|
||||
`Test Template ${id}`,
|
||||
'Test Description',
|
||||
1000,
|
||||
JSON.stringify(['n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest'])
|
||||
);
|
||||
}
|
||||
|
||||
// Insert webhook configs
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, credentials_json,
|
||||
has_credentials, has_expressions, complexity, use_cases, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
...Object.values(sampleConfigs.simpleWebhook)
|
||||
);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, credentials_json,
|
||||
has_credentials, has_expressions, complexity, use_cases, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
...Object.values(sampleConfigs.webhookWithAuth)
|
||||
);
|
||||
|
||||
// Insert HTTP request configs
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, credentials_json,
|
||||
has_credentials, has_expressions, complexity, use_cases, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
...Object.values(sampleConfigs.httpRequestBasic)
|
||||
);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, credentials_json,
|
||||
has_credentials, has_expressions, complexity, use_cases, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
...Object.values(sampleConfigs.httpRequestWithExpressions)
|
||||
);
|
||||
}
|
||||
|
||||
describe('Querying Examples Directly', () => {
|
||||
it('should fetch top 2 examples for webhook node', () => {
|
||||
const examples = db.prepare(`
|
||||
SELECT
|
||||
parameters_json,
|
||||
template_name,
|
||||
template_views
|
||||
FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
ORDER BY rank
|
||||
LIMIT 2
|
||||
`).all('n8n-nodes-base.webhook') as any[];
|
||||
|
||||
expect(examples).toHaveLength(2);
|
||||
expect(examples[0].template_name).toBe('Simple Webhook Trigger');
|
||||
expect(examples[1].template_name).toBe('Authenticated Webhook');
|
||||
});
|
||||
|
||||
it('should fetch top 3 examples with metadata for HTTP request node', () => {
|
||||
const examples = db.prepare(`
|
||||
SELECT
|
||||
parameters_json,
|
||||
template_name,
|
||||
template_views,
|
||||
complexity,
|
||||
use_cases,
|
||||
has_credentials,
|
||||
has_expressions
|
||||
FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
ORDER BY rank
|
||||
LIMIT 3
|
||||
`).all('n8n-nodes-base.httpRequest') as any[];
|
||||
|
||||
expect(examples).toHaveLength(2); // Only 2 inserted
|
||||
expect(examples[0].template_name).toBe('Basic HTTP GET Request');
|
||||
expect(examples[0].complexity).toBe('simple');
|
||||
expect(examples[0].has_expressions).toBe(0);
|
||||
|
||||
expect(examples[1].template_name).toBe('Dynamic HTTP Request');
|
||||
expect(examples[1].complexity).toBe('complex');
|
||||
expect(examples[1].has_expressions).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Example Data Structure Validation', () => {
|
||||
it('should have valid JSON in parameters_json', () => {
|
||||
const examples = db.prepare(`
|
||||
SELECT parameters_json
|
||||
FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
LIMIT 1
|
||||
`).all('n8n-nodes-base.webhook') as any[];
|
||||
|
||||
expect(() => {
|
||||
const params = JSON.parse(examples[0].parameters_json);
|
||||
expect(params).toHaveProperty('httpMethod');
|
||||
expect(params).toHaveProperty('path');
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should have valid JSON in use_cases', () => {
|
||||
const examples = db.prepare(`
|
||||
SELECT use_cases
|
||||
FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
LIMIT 1
|
||||
`).all('n8n-nodes-base.webhook') as any[];
|
||||
|
||||
expect(() => {
|
||||
const useCases = JSON.parse(examples[0].use_cases);
|
||||
expect(Array.isArray(useCases)).toBe(true);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should have credentials_json when has_credentials is 1', () => {
|
||||
const examples = db.prepare(`
|
||||
SELECT credentials_json, has_credentials
|
||||
FROM template_node_configs
|
||||
WHERE has_credentials = 1
|
||||
LIMIT 1
|
||||
`).all() as any[];
|
||||
|
||||
if (examples.length > 0) {
|
||||
expect(examples[0].credentials_json).not.toBeNull();
|
||||
expect(() => {
|
||||
JSON.parse(examples[0].credentials_json);
|
||||
}).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ranked View Functionality', () => {
|
||||
it('should return only top 5 ranked configs per node type from view', () => {
|
||||
// Insert templates first to satisfy foreign key constraints
|
||||
// Note: seedTemplateConfigs already created templates 1-4, so start from 5
|
||||
for (let i = 5; i <= 14; i++) {
|
||||
db.prepare(`
|
||||
INSERT INTO templates (
|
||||
id, workflow_id, name, description, views,
|
||||
nodes_used, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
`).run(i, i, `Template ${i}`, 'Test', 1000 - (i * 50), '[]');
|
||||
}
|
||||
|
||||
// Insert 10 configs for same node type
|
||||
for (let i = 5; i <= 14; i++) {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.webhook',
|
||||
i,
|
||||
`Template ${i}`,
|
||||
1000 - (i * 50),
|
||||
'Webhook',
|
||||
'{}',
|
||||
i
|
||||
);
|
||||
}
|
||||
|
||||
const rankedConfigs = db.prepare(`
|
||||
SELECT * FROM ranked_node_configs
|
||||
WHERE node_type = ?
|
||||
`).all('n8n-nodes-base.webhook') as any[];
|
||||
|
||||
expect(rankedConfigs.length).toBeLessThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance with Real-World Data Volume', () => {
|
||||
beforeEach(() => {
|
||||
// Insert templates first to satisfy foreign key constraints
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
db.prepare(`
|
||||
INSERT INTO templates (
|
||||
id, workflow_id, name, description, views,
|
||||
nodes_used, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
`).run(i + 100, i + 100, `Template ${i}`, 'Test', Math.floor(Math.random() * 10000), '[]');
|
||||
}
|
||||
|
||||
// Insert 100 configs across 10 different node types
|
||||
const nodeTypes = [
|
||||
'n8n-nodes-base.slack',
|
||||
'n8n-nodes-base.googleSheets',
|
||||
'n8n-nodes-base.code',
|
||||
'n8n-nodes-base.if',
|
||||
'n8n-nodes-base.switch',
|
||||
'n8n-nodes-base.set',
|
||||
'n8n-nodes-base.merge',
|
||||
'n8n-nodes-base.splitInBatches',
|
||||
'n8n-nodes-base.postgres',
|
||||
'n8n-nodes-base.gmail'
|
||||
];
|
||||
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
const nodeType = nodeTypes[i % nodeTypes.length];
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
nodeType,
|
||||
i + 100, // Offset template_id
|
||||
`Template ${i}`,
|
||||
Math.floor(Math.random() * 10000),
|
||||
'Node',
|
||||
'{}',
|
||||
(i % 10) + 1
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should query specific node type examples quickly', () => {
|
||||
const start = Date.now();
|
||||
const examples = db.prepare(`
|
||||
SELECT * FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
ORDER BY rank
|
||||
LIMIT 3
|
||||
`).all('n8n-nodes-base.slack') as any[];
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(examples.length).toBeGreaterThan(0);
|
||||
expect(duration).toBeLessThan(5); // Should be very fast with index
|
||||
});
|
||||
|
||||
it('should filter by complexity efficiently', () => {
|
||||
// Set complexity on configs
|
||||
db.exec(`UPDATE template_node_configs SET complexity = 'simple' WHERE id % 3 = 0`);
|
||||
db.exec(`UPDATE template_node_configs SET complexity = 'medium' WHERE id % 3 = 1`);
|
||||
|
||||
const start = Date.now();
|
||||
const examples = db.prepare(`
|
||||
SELECT * FROM template_node_configs
|
||||
WHERE node_type = ? AND complexity = ?
|
||||
ORDER BY rank
|
||||
LIMIT 3
|
||||
`).all('n8n-nodes-base.code', 'simple') as any[];
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(duration).toBeLessThan(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle node types with no configs', () => {
|
||||
const examples = db.prepare(`
|
||||
SELECT * FROM template_node_configs
|
||||
WHERE node_type = ?
|
||||
LIMIT 2
|
||||
`).all('n8n-nodes-base.nonexistent') as any[];
|
||||
|
||||
expect(examples).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle very long parameters_json', () => {
|
||||
const longParams = JSON.stringify({
|
||||
options: {
|
||||
queryParameters: Array.from({ length: 100 }, (_, i) => ({
|
||||
name: `param${i}`,
|
||||
value: `value${i}`.repeat(10)
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
999,
|
||||
'Long Params Template',
|
||||
100,
|
||||
'Test',
|
||||
longParams,
|
||||
1
|
||||
);
|
||||
|
||||
const example = db.prepare(`
|
||||
SELECT parameters_json FROM template_node_configs WHERE template_id = ?
|
||||
`).get(999) as any;
|
||||
|
||||
expect(() => {
|
||||
const parsed = JSON.parse(example.parameters_json);
|
||||
expect(parsed.options.queryParameters).toHaveLength(100);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle special characters in parameters', () => {
|
||||
const specialParams = JSON.stringify({
|
||||
message: "Test with 'quotes' and \"double quotes\"",
|
||||
unicode: "特殊文字 🎉 émojis",
|
||||
symbols: "!@#$%^&*()_+-={}[]|\\:;<>?,./"
|
||||
});
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
998,
|
||||
'Special Chars Template',
|
||||
100,
|
||||
'Test',
|
||||
specialParams,
|
||||
1
|
||||
);
|
||||
|
||||
const example = db.prepare(`
|
||||
SELECT parameters_json FROM template_node_configs WHERE template_id = ?
|
||||
`).get(998) as any;
|
||||
|
||||
expect(() => {
|
||||
const parsed = JSON.parse(example.parameters_json);
|
||||
expect(parsed.message).toContain("'quotes'");
|
||||
expect(parsed.unicode).toContain("🎉");
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Integrity', () => {
|
||||
it('should maintain referential integrity with templates table', () => {
|
||||
// Try to insert config with non-existent template_id (with FK enabled)
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
expect(() => {
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
999999, // Non-existent template_id
|
||||
'Test',
|
||||
100,
|
||||
'Node',
|
||||
'{}',
|
||||
1
|
||||
);
|
||||
}).toThrow(); // Should fail due to FK constraint
|
||||
});
|
||||
|
||||
it('should cascade delete configs when template is deleted', () => {
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
|
||||
// Insert a new template (use id 1000 to avoid conflicts with seedTemplateConfigs)
|
||||
db.prepare(`
|
||||
INSERT INTO templates (
|
||||
id, workflow_id, name, description, views,
|
||||
nodes_used, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
`).run(1000, 1000, 'Test Template 1000', 'Desc', 100, '[]');
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO template_node_configs (
|
||||
node_type, template_id, template_name, template_views,
|
||||
node_name, parameters_json, rank
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
'n8n-nodes-base.test',
|
||||
1000,
|
||||
'Test',
|
||||
100,
|
||||
'Node',
|
||||
'{}',
|
||||
1
|
||||
);
|
||||
|
||||
// Verify config exists
|
||||
let config = db.prepare('SELECT * FROM template_node_configs WHERE template_id = ?').get(1000);
|
||||
expect(config).toBeDefined();
|
||||
|
||||
// Delete template
|
||||
db.prepare('DELETE FROM templates WHERE id = ?').run(1000);
|
||||
|
||||
// Verify config is deleted (CASCADE)
|
||||
config = db.prepare('SELECT * FROM template_node_configs WHERE template_id = ?').get(1000);
|
||||
expect(config).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
43
tests/integration/n8n-api/scripts/cleanup-orphans.ts
Normal file
43
tests/integration/n8n-api/scripts/cleanup-orphans.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Cleanup Orphaned Test Resources
|
||||
*
|
||||
* Standalone script to clean up orphaned workflows and executions
|
||||
* from failed test runs. Run this periodically in CI or manually
|
||||
* to maintain a clean test environment.
|
||||
*
|
||||
* Usage:
|
||||
* npm run test:cleanup:orphans
|
||||
* tsx tests/integration/n8n-api/scripts/cleanup-orphans.ts
|
||||
*/
|
||||
|
||||
import { cleanupAllTestResources } from '../utils/cleanup-helpers';
|
||||
import { getN8nCredentials, validateCredentials } from '../utils/credentials';
|
||||
|
||||
async function main() {
|
||||
console.log('Starting cleanup of orphaned test resources...\n');
|
||||
|
||||
try {
|
||||
// Validate credentials
|
||||
const creds = getN8nCredentials();
|
||||
validateCredentials(creds);
|
||||
|
||||
console.log(`n8n Instance: ${creds.url}`);
|
||||
console.log(`Cleanup Tag: ${creds.cleanup.tag}`);
|
||||
console.log(`Cleanup Prefix: ${creds.cleanup.namePrefix}\n`);
|
||||
|
||||
// Run cleanup
|
||||
const result = await cleanupAllTestResources();
|
||||
|
||||
console.log('\n✅ Cleanup complete!');
|
||||
console.log(` Workflows deleted: ${result.workflows}`);
|
||||
console.log(` Executions deleted: ${result.executions}`);
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('\n❌ Cleanup failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
299
tests/integration/n8n-api/utils/cleanup-helpers.ts
Normal file
299
tests/integration/n8n-api/utils/cleanup-helpers.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Cleanup Helpers for Integration Tests
|
||||
*
|
||||
* Provides multi-level cleanup strategies for test resources:
|
||||
* - Orphaned workflows (from failed test runs)
|
||||
* - Old executions (older than 24 hours)
|
||||
* - Bulk cleanup by tag or name prefix
|
||||
*/
|
||||
|
||||
import { getTestN8nClient } from './n8n-client';
|
||||
import { getN8nCredentials } from './credentials';
|
||||
import { Logger } from '../../../../src/utils/logger';
|
||||
|
||||
const logger = new Logger({ prefix: '[Cleanup]' });
|
||||
|
||||
/**
|
||||
* Clean up orphaned test workflows
|
||||
*
|
||||
* Finds and deletes all workflows tagged with the test tag or
|
||||
* prefixed with the test name prefix. Run this periodically in CI
|
||||
* to clean up failed test runs.
|
||||
*
|
||||
* @returns Array of deleted workflow IDs
|
||||
*/
|
||||
export async function cleanupOrphanedWorkflows(): Promise<string[]> {
|
||||
const creds = getN8nCredentials();
|
||||
const client = getTestN8nClient();
|
||||
const deleted: string[] = [];
|
||||
|
||||
logger.info('Searching for orphaned test workflows...');
|
||||
|
||||
let allWorkflows: any[] = [];
|
||||
let cursor: string | undefined;
|
||||
let pageCount = 0;
|
||||
const MAX_PAGES = 1000; // Safety limit to prevent infinite loops
|
||||
|
||||
// Fetch all workflows with pagination
|
||||
try {
|
||||
do {
|
||||
pageCount++;
|
||||
|
||||
if (pageCount > MAX_PAGES) {
|
||||
logger.error(`Exceeded maximum pages (${MAX_PAGES}). Possible infinite loop or API issue.`);
|
||||
throw new Error('Pagination safety limit exceeded while fetching workflows');
|
||||
}
|
||||
|
||||
logger.debug(`Fetching workflows page ${pageCount}...`);
|
||||
|
||||
const response = await client.listWorkflows({
|
||||
cursor,
|
||||
limit: 100,
|
||||
excludePinnedData: true
|
||||
});
|
||||
|
||||
allWorkflows.push(...response.data);
|
||||
cursor = response.nextCursor || undefined;
|
||||
} while (cursor);
|
||||
|
||||
logger.info(`Found ${allWorkflows.length} total workflows across ${pageCount} page(s)`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch workflows:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Find test workflows
|
||||
const testWorkflows = allWorkflows.filter(w =>
|
||||
w.tags?.includes(creds.cleanup.tag) ||
|
||||
w.name?.startsWith(creds.cleanup.namePrefix)
|
||||
);
|
||||
|
||||
logger.info(`Found ${testWorkflows.length} orphaned test workflow(s)`);
|
||||
|
||||
if (testWorkflows.length === 0) {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// Delete them
|
||||
for (const workflow of testWorkflows) {
|
||||
try {
|
||||
await client.deleteWorkflow(workflow.id);
|
||||
deleted.push(workflow.id);
|
||||
logger.debug(`Deleted orphaned workflow: ${workflow.name} (${workflow.id})`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete workflow ${workflow.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Successfully deleted ${deleted.length} orphaned workflow(s)`);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old executions
|
||||
*
|
||||
* Deletes executions older than the specified age.
|
||||
*
|
||||
* @param maxAgeMs - Maximum age in milliseconds (default: 24 hours)
|
||||
* @returns Array of deleted execution IDs
|
||||
*/
|
||||
export async function cleanupOldExecutions(
|
||||
maxAgeMs: number = 24 * 60 * 60 * 1000
|
||||
): Promise<string[]> {
|
||||
const client = getTestN8nClient();
|
||||
const deleted: string[] = [];
|
||||
|
||||
logger.info(`Searching for executions older than ${maxAgeMs}ms...`);
|
||||
|
||||
let allExecutions: any[] = [];
|
||||
let cursor: string | undefined;
|
||||
let pageCount = 0;
|
||||
const MAX_PAGES = 1000; // Safety limit to prevent infinite loops
|
||||
|
||||
// Fetch all executions
|
||||
try {
|
||||
do {
|
||||
pageCount++;
|
||||
|
||||
if (pageCount > MAX_PAGES) {
|
||||
logger.error(`Exceeded maximum pages (${MAX_PAGES}). Possible infinite loop or API issue.`);
|
||||
throw new Error('Pagination safety limit exceeded while fetching executions');
|
||||
}
|
||||
|
||||
logger.debug(`Fetching executions page ${pageCount}...`);
|
||||
|
||||
const response = await client.listExecutions({
|
||||
cursor,
|
||||
limit: 100,
|
||||
includeData: false
|
||||
});
|
||||
|
||||
allExecutions.push(...response.data);
|
||||
cursor = response.nextCursor || undefined;
|
||||
} while (cursor);
|
||||
|
||||
logger.info(`Found ${allExecutions.length} total executions across ${pageCount} page(s)`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch executions:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const cutoffTime = Date.now() - maxAgeMs;
|
||||
const oldExecutions = allExecutions.filter(e => {
|
||||
const executionTime = new Date(e.startedAt).getTime();
|
||||
return executionTime < cutoffTime;
|
||||
});
|
||||
|
||||
logger.info(`Found ${oldExecutions.length} old execution(s)`);
|
||||
|
||||
if (oldExecutions.length === 0) {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
for (const execution of oldExecutions) {
|
||||
try {
|
||||
await client.deleteExecution(execution.id);
|
||||
deleted.push(execution.id);
|
||||
logger.debug(`Deleted old execution: ${execution.id}`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete execution ${execution.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Successfully deleted ${deleted.length} old execution(s)`);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all test resources
|
||||
*
|
||||
* Combines cleanupOrphanedWorkflows and cleanupOldExecutions.
|
||||
* Use this as a comprehensive cleanup in CI.
|
||||
*
|
||||
* @returns Object with counts of deleted resources
|
||||
*/
|
||||
export async function cleanupAllTestResources(): Promise<{
|
||||
workflows: number;
|
||||
executions: number;
|
||||
}> {
|
||||
logger.info('Starting comprehensive test resource cleanup...');
|
||||
|
||||
const [workflowIds, executionIds] = await Promise.all([
|
||||
cleanupOrphanedWorkflows(),
|
||||
cleanupOldExecutions()
|
||||
]);
|
||||
|
||||
logger.info(
|
||||
`Cleanup complete: ${workflowIds.length} workflows, ${executionIds.length} executions`
|
||||
);
|
||||
|
||||
return {
|
||||
workflows: workflowIds.length,
|
||||
executions: executionIds.length
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete workflows by tag
|
||||
*
|
||||
* Deletes all workflows with the specified tag.
|
||||
*
|
||||
* @param tag - Tag to match
|
||||
* @returns Array of deleted workflow IDs
|
||||
*/
|
||||
export async function cleanupWorkflowsByTag(tag: string): Promise<string[]> {
|
||||
const client = getTestN8nClient();
|
||||
const deleted: string[] = [];
|
||||
|
||||
logger.info(`Searching for workflows with tag: ${tag}`);
|
||||
|
||||
try {
|
||||
const response = await client.listWorkflows({
|
||||
tags: tag ? [tag] : undefined,
|
||||
limit: 100,
|
||||
excludePinnedData: true
|
||||
});
|
||||
|
||||
const workflows = response.data;
|
||||
logger.info(`Found ${workflows.length} workflow(s) with tag: ${tag}`);
|
||||
|
||||
for (const workflow of workflows) {
|
||||
if (!workflow.id) continue;
|
||||
|
||||
try {
|
||||
await client.deleteWorkflow(workflow.id);
|
||||
deleted.push(workflow.id);
|
||||
logger.debug(`Deleted workflow: ${workflow.name} (${workflow.id})`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete workflow ${workflow.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Successfully deleted ${deleted.length} workflow(s)`);
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to cleanup workflows by tag: ${tag}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete executions for a specific workflow
|
||||
*
|
||||
* @param workflowId - Workflow ID
|
||||
* @returns Array of deleted execution IDs
|
||||
*/
|
||||
export async function cleanupExecutionsByWorkflow(
|
||||
workflowId: string
|
||||
): Promise<string[]> {
|
||||
const client = getTestN8nClient();
|
||||
const deleted: string[] = [];
|
||||
|
||||
logger.info(`Searching for executions of workflow: ${workflowId}`);
|
||||
|
||||
let cursor: string | undefined;
|
||||
let totalCount = 0;
|
||||
let pageCount = 0;
|
||||
const MAX_PAGES = 1000; // Safety limit to prevent infinite loops
|
||||
|
||||
try {
|
||||
do {
|
||||
pageCount++;
|
||||
|
||||
if (pageCount > MAX_PAGES) {
|
||||
logger.error(`Exceeded maximum pages (${MAX_PAGES}). Possible infinite loop or API issue.`);
|
||||
throw new Error(`Pagination safety limit exceeded while fetching executions for workflow ${workflowId}`);
|
||||
}
|
||||
|
||||
const response = await client.listExecutions({
|
||||
workflowId,
|
||||
cursor,
|
||||
limit: 100,
|
||||
includeData: false
|
||||
});
|
||||
|
||||
const executions = response.data;
|
||||
totalCount += executions.length;
|
||||
|
||||
for (const execution of executions) {
|
||||
try {
|
||||
await client.deleteExecution(execution.id);
|
||||
deleted.push(execution.id);
|
||||
logger.debug(`Deleted execution: ${execution.id}`);
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to delete execution ${execution.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
cursor = response.nextCursor || undefined;
|
||||
} while (cursor);
|
||||
|
||||
logger.info(
|
||||
`Successfully deleted ${deleted.length}/${totalCount} execution(s) for workflow ${workflowId}`
|
||||
);
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to cleanup executions for workflow: ${workflowId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
194
tests/integration/n8n-api/utils/credentials.ts
Normal file
194
tests/integration/n8n-api/utils/credentials.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Integration Test Credentials Management
|
||||
*
|
||||
* Provides environment-aware credential loading for integration tests.
|
||||
* - Local development: Reads from .env file
|
||||
* - CI/GitHub Actions: Uses GitHub secrets from process.env
|
||||
*/
|
||||
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
// Load .env file for local development
|
||||
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
|
||||
|
||||
export interface N8nTestCredentials {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
webhookWorkflows: {
|
||||
get: string;
|
||||
post: string;
|
||||
put: string;
|
||||
delete: string;
|
||||
};
|
||||
cleanup: {
|
||||
enabled: boolean;
|
||||
tag: string;
|
||||
namePrefix: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get n8n credentials for integration tests
|
||||
*
|
||||
* Automatically detects environment (local vs CI) and loads
|
||||
* credentials from the appropriate source.
|
||||
*
|
||||
* @returns N8nTestCredentials
|
||||
* @throws Error if required credentials are missing
|
||||
*/
|
||||
export function getN8nCredentials(): N8nTestCredentials {
|
||||
if (process.env.CI) {
|
||||
// CI: Use GitHub secrets - validate required variables first
|
||||
const url = process.env.N8N_URL;
|
||||
const apiKey = process.env.N8N_API_KEY;
|
||||
|
||||
if (!url || !apiKey) {
|
||||
throw new Error(
|
||||
'Missing required CI credentials:\n' +
|
||||
` N8N_URL: ${url ? 'set' : 'MISSING'}\n` +
|
||||
` N8N_API_KEY: ${apiKey ? 'set' : 'MISSING'}\n` +
|
||||
'Please configure GitHub secrets for integration tests.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
apiKey,
|
||||
webhookWorkflows: {
|
||||
get: process.env.N8N_TEST_WEBHOOK_GET_ID || '',
|
||||
post: process.env.N8N_TEST_WEBHOOK_POST_ID || '',
|
||||
put: process.env.N8N_TEST_WEBHOOK_PUT_ID || '',
|
||||
delete: process.env.N8N_TEST_WEBHOOK_DELETE_ID || ''
|
||||
},
|
||||
cleanup: {
|
||||
enabled: true,
|
||||
tag: 'mcp-integration-test',
|
||||
namePrefix: '[MCP-TEST]'
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Local: Use .env file - validate required variables first
|
||||
const url = process.env.N8N_API_URL;
|
||||
const apiKey = process.env.N8N_API_KEY;
|
||||
|
||||
if (!url || !apiKey) {
|
||||
throw new Error(
|
||||
'Missing required credentials in .env:\n' +
|
||||
` N8N_API_URL: ${url ? 'set' : 'MISSING'}\n` +
|
||||
` N8N_API_KEY: ${apiKey ? 'set' : 'MISSING'}\n\n` +
|
||||
'Please add these to your .env file.\n' +
|
||||
'See .env.example for configuration details.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
apiKey,
|
||||
webhookWorkflows: {
|
||||
get: process.env.N8N_TEST_WEBHOOK_GET_ID || '',
|
||||
post: process.env.N8N_TEST_WEBHOOK_POST_ID || '',
|
||||
put: process.env.N8N_TEST_WEBHOOK_PUT_ID || '',
|
||||
delete: process.env.N8N_TEST_WEBHOOK_DELETE_ID || ''
|
||||
},
|
||||
cleanup: {
|
||||
enabled: process.env.N8N_TEST_CLEANUP_ENABLED !== 'false',
|
||||
tag: process.env.N8N_TEST_TAG || 'mcp-integration-test',
|
||||
namePrefix: process.env.N8N_TEST_NAME_PREFIX || '[MCP-TEST]'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that required credentials are present
|
||||
*
|
||||
* @param creds - Credentials to validate
|
||||
* @throws Error if required credentials are missing
|
||||
*/
|
||||
export function validateCredentials(creds: N8nTestCredentials): void {
|
||||
const missing: string[] = [];
|
||||
|
||||
if (!creds.url) {
|
||||
missing.push(process.env.CI ? 'N8N_URL' : 'N8N_API_URL');
|
||||
}
|
||||
if (!creds.apiKey) {
|
||||
missing.push('N8N_API_KEY');
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required n8n credentials: ${missing.join(', ')}\n\n` +
|
||||
`Please set the following environment variables:\n` +
|
||||
missing.map(v => ` ${v}`).join('\n') + '\n\n' +
|
||||
`See .env.example for configuration details.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that webhook workflow IDs are configured
|
||||
*
|
||||
* @param creds - Credentials to validate
|
||||
* @throws Error with setup instructions if webhook workflows are missing
|
||||
*/
|
||||
export function validateWebhookWorkflows(creds: N8nTestCredentials): void {
|
||||
const missing: string[] = [];
|
||||
|
||||
if (!creds.webhookWorkflows.get) missing.push('GET');
|
||||
if (!creds.webhookWorkflows.post) missing.push('POST');
|
||||
if (!creds.webhookWorkflows.put) missing.push('PUT');
|
||||
if (!creds.webhookWorkflows.delete) missing.push('DELETE');
|
||||
|
||||
if (missing.length > 0) {
|
||||
const envVars = missing.map(m => `N8N_TEST_WEBHOOK_${m}_ID`);
|
||||
|
||||
throw new Error(
|
||||
`Missing webhook workflow IDs for HTTP methods: ${missing.join(', ')}\n\n` +
|
||||
`Webhook testing requires pre-activated workflows in n8n.\n` +
|
||||
`n8n API doesn't support workflow activation, so these must be created manually.\n\n` +
|
||||
`Setup Instructions:\n` +
|
||||
`1. Create ${missing.length} workflow(s) in your n8n instance\n` +
|
||||
`2. Each workflow should have a single Webhook node\n` +
|
||||
`3. Configure webhook paths:\n` +
|
||||
missing.map(m => ` - ${m}: mcp-test-${m.toLowerCase()}`).join('\n') + '\n' +
|
||||
`4. ACTIVATE each workflow in n8n UI\n` +
|
||||
`5. Set the following environment variables with workflow IDs:\n` +
|
||||
envVars.map(v => ` ${v}=<workflow-id>`).join('\n') + '\n\n' +
|
||||
`See docs/local/integration-testing-plan.md for detailed instructions.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if credentials are configured (non-throwing version)
|
||||
*
|
||||
* @returns true if basic credentials are available
|
||||
*/
|
||||
export function hasCredentials(): boolean {
|
||||
try {
|
||||
const creds = getN8nCredentials();
|
||||
return !!(creds.url && creds.apiKey);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if webhook workflows are configured (non-throwing version)
|
||||
*
|
||||
* @returns true if all webhook workflow IDs are available
|
||||
*/
|
||||
export function hasWebhookWorkflows(): boolean {
|
||||
try {
|
||||
const creds = getN8nCredentials();
|
||||
return !!(
|
||||
creds.webhookWorkflows.get &&
|
||||
creds.webhookWorkflows.post &&
|
||||
creds.webhookWorkflows.put &&
|
||||
creds.webhookWorkflows.delete
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
326
tests/integration/n8n-api/utils/factories.ts
Normal file
326
tests/integration/n8n-api/utils/factories.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Test Data Factories
|
||||
*
|
||||
* Provides factory functions for generating test data dynamically.
|
||||
* Useful for creating variations of workflows, nodes, and parameters.
|
||||
*/
|
||||
|
||||
import { Workflow, WorkflowNode } from '../../../../src/types/n8n-api';
|
||||
import { createTestWorkflowName } from './test-context';
|
||||
|
||||
/**
|
||||
* Create a webhook node with custom parameters
|
||||
*
|
||||
* @param options - Node options
|
||||
* @returns WorkflowNode
|
||||
*/
|
||||
export function createWebhookNode(options: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
httpMethod?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
path?: string;
|
||||
position?: [number, number];
|
||||
responseMode?: 'onReceived' | 'lastNode';
|
||||
}): WorkflowNode {
|
||||
return {
|
||||
id: options.id || `webhook-${Date.now()}`,
|
||||
name: options.name || 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: options.position || [250, 300],
|
||||
parameters: {
|
||||
httpMethod: options.httpMethod || 'GET',
|
||||
path: options.path || `test-${Date.now()}`,
|
||||
responseMode: options.responseMode || 'lastNode'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTTP Request node with custom parameters
|
||||
*
|
||||
* @param options - Node options
|
||||
* @returns WorkflowNode
|
||||
*/
|
||||
export function createHttpRequestNode(options: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
position?: [number, number];
|
||||
authentication?: string;
|
||||
}): WorkflowNode {
|
||||
return {
|
||||
id: options.id || `http-${Date.now()}`,
|
||||
name: options.name || 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4.2,
|
||||
position: options.position || [450, 300],
|
||||
parameters: {
|
||||
url: options.url || 'https://httpbin.org/get',
|
||||
method: options.method || 'GET',
|
||||
authentication: options.authentication || 'none'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Set node with custom assignments
|
||||
*
|
||||
* @param options - Node options
|
||||
* @returns WorkflowNode
|
||||
*/
|
||||
export function createSetNode(options: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
position?: [number, number];
|
||||
assignments?: Array<{
|
||||
name: string;
|
||||
value: any;
|
||||
type?: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||
}>;
|
||||
}): WorkflowNode {
|
||||
const assignments = options.assignments || [
|
||||
{ name: 'key', value: 'value', type: 'string' as const }
|
||||
];
|
||||
|
||||
return {
|
||||
id: options.id || `set-${Date.now()}`,
|
||||
name: options.name || 'Set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: options.position || [450, 300],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: assignments.map((a, idx) => ({
|
||||
id: `assign-${idx}`,
|
||||
name: a.name,
|
||||
value: a.value,
|
||||
type: a.type || 'string'
|
||||
}))
|
||||
},
|
||||
options: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Manual Trigger node
|
||||
*
|
||||
* @param options - Node options
|
||||
* @returns WorkflowNode
|
||||
*/
|
||||
export function createManualTriggerNode(options: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
position?: [number, number];
|
||||
} = {}): WorkflowNode {
|
||||
return {
|
||||
id: options.id || `manual-${Date.now()}`,
|
||||
name: options.name || 'When clicking "Test workflow"',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: options.position || [250, 300],
|
||||
parameters: {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple connection between two nodes
|
||||
*
|
||||
* @param from - Source node name
|
||||
* @param to - Target node name
|
||||
* @param options - Connection options
|
||||
* @returns Connection object
|
||||
*/
|
||||
export function createConnection(
|
||||
from: string,
|
||||
to: string,
|
||||
options: {
|
||||
sourceOutput?: string;
|
||||
targetInput?: string;
|
||||
sourceIndex?: number;
|
||||
targetIndex?: number;
|
||||
} = {}
|
||||
): Record<string, any> {
|
||||
const sourceOutput = options.sourceOutput || 'main';
|
||||
const targetInput = options.targetInput || 'main';
|
||||
const sourceIndex = options.sourceIndex || 0;
|
||||
const targetIndex = options.targetIndex || 0;
|
||||
|
||||
return {
|
||||
[from]: {
|
||||
[sourceOutput]: [
|
||||
[
|
||||
{
|
||||
node: to,
|
||||
type: targetInput,
|
||||
index: targetIndex
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workflow from nodes with automatic connections
|
||||
*
|
||||
* Connects nodes in sequence: node1 -> node2 -> node3, etc.
|
||||
*
|
||||
* @param name - Workflow name
|
||||
* @param nodes - Array of nodes
|
||||
* @returns Partial workflow
|
||||
*/
|
||||
export function createSequentialWorkflow(
|
||||
name: string,
|
||||
nodes: WorkflowNode[]
|
||||
): Partial<Workflow> {
|
||||
const connections: Record<string, any> = {};
|
||||
|
||||
// Create connections between sequential nodes
|
||||
for (let i = 0; i < nodes.length - 1; i++) {
|
||||
const currentNode = nodes[i];
|
||||
const nextNode = nodes[i + 1];
|
||||
|
||||
connections[currentNode.name] = {
|
||||
main: [[{ node: nextNode.name, type: 'main', index: 0 }]]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: createTestWorkflowName(name),
|
||||
nodes,
|
||||
connections,
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workflow with parallel branches
|
||||
*
|
||||
* Creates a workflow with one trigger node that splits into multiple
|
||||
* parallel execution paths.
|
||||
*
|
||||
* @param name - Workflow name
|
||||
* @param trigger - Trigger node
|
||||
* @param branches - Array of branch nodes
|
||||
* @returns Partial workflow
|
||||
*/
|
||||
export function createParallelWorkflow(
|
||||
name: string,
|
||||
trigger: WorkflowNode,
|
||||
branches: WorkflowNode[]
|
||||
): Partial<Workflow> {
|
||||
const connections: Record<string, any> = {
|
||||
[trigger.name]: {
|
||||
main: [branches.map(node => ({ node: node.name, type: 'main', index: 0 }))]
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
name: createTestWorkflowName(name),
|
||||
nodes: [trigger, ...branches],
|
||||
connections,
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random string for test data
|
||||
*
|
||||
* @param length - String length (default: 8)
|
||||
* @returns Random string
|
||||
*/
|
||||
export function randomString(length: number = 8): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for testing
|
||||
*
|
||||
* @param prefix - Optional prefix
|
||||
* @returns Unique ID
|
||||
*/
|
||||
export function uniqueId(prefix: string = 'test'): string {
|
||||
return `${prefix}-${Date.now()}-${randomString(4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workflow with error handling
|
||||
*
|
||||
* @param name - Workflow name
|
||||
* @param mainNode - Main processing node
|
||||
* @param errorNode - Error handling node
|
||||
* @returns Partial workflow with error handling configured
|
||||
*/
|
||||
export function createErrorHandlingWorkflow(
|
||||
name: string,
|
||||
mainNode: WorkflowNode,
|
||||
errorNode: WorkflowNode
|
||||
): Partial<Workflow> {
|
||||
const trigger = createWebhookNode({
|
||||
name: 'Trigger',
|
||||
position: [250, 300]
|
||||
});
|
||||
|
||||
// Configure main node for error handling
|
||||
const mainNodeWithError = {
|
||||
...mainNode,
|
||||
continueOnFail: true,
|
||||
onError: 'continueErrorOutput' as const
|
||||
};
|
||||
|
||||
const connections: Record<string, any> = {
|
||||
[trigger.name]: {
|
||||
main: [[{ node: mainNode.name, type: 'main', index: 0 }]]
|
||||
},
|
||||
[mainNode.name]: {
|
||||
error: [[{ node: errorNode.name, type: 'main', index: 0 }]]
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
name: createTestWorkflowName(name),
|
||||
nodes: [trigger, mainNodeWithError, errorNode],
|
||||
connections,
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create test workflow tags
|
||||
*
|
||||
* @param additional - Additional tags to include
|
||||
* @returns Array of tags for test workflows
|
||||
*/
|
||||
export function createTestTags(additional: string[] = []): string[] {
|
||||
return ['mcp-integration-test', ...additional];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create workflow settings with common test configurations
|
||||
*
|
||||
* @param overrides - Settings to override
|
||||
* @returns Workflow settings object
|
||||
*/
|
||||
export function createWorkflowSettings(overrides: Record<string, any> = {}): Record<string, any> {
|
||||
return {
|
||||
executionOrder: 'v1',
|
||||
saveDataErrorExecution: 'all',
|
||||
saveDataSuccessExecution: 'all',
|
||||
saveManualExecutions: true,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
374
tests/integration/n8n-api/utils/fixtures.ts
Normal file
374
tests/integration/n8n-api/utils/fixtures.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Workflow Fixtures for Integration Tests
|
||||
*
|
||||
* Provides reusable workflow templates for testing.
|
||||
* All fixtures use FULL node type format (n8n-nodes-base.*)
|
||||
* as required by the n8n API.
|
||||
*/
|
||||
|
||||
import { Workflow, WorkflowNode } from '../../../../src/types/n8n-api';
|
||||
|
||||
/**
|
||||
* Simple webhook workflow with a single Webhook node
|
||||
*
|
||||
* Use this for basic workflow creation tests.
|
||||
*/
|
||||
export const SIMPLE_WEBHOOK_WORKFLOW: Partial<Workflow> = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'test-webhook'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {},
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple HTTP request workflow
|
||||
*
|
||||
* Contains a Webhook trigger and an HTTP Request node.
|
||||
* Tests basic workflow connections.
|
||||
*/
|
||||
export const SIMPLE_HTTP_WORKFLOW: Partial<Workflow> = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'trigger'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'http-1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4.2,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
url: 'https://httpbin.org/get',
|
||||
method: 'GET'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Multi-node workflow with branching
|
||||
*
|
||||
* Tests complex connections and multiple execution paths.
|
||||
*/
|
||||
export const MULTI_NODE_WORKFLOW: Partial<Workflow> = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'POST',
|
||||
path: 'multi-node'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'set-1',
|
||||
name: 'Set 1',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [450, 200],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{
|
||||
id: 'assign-1',
|
||||
name: 'branch',
|
||||
value: 'top',
|
||||
type: 'string'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'set-2',
|
||||
name: 'Set 2',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [450, 400],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{
|
||||
id: 'assign-2',
|
||||
name: 'branch',
|
||||
value: 'bottom',
|
||||
type: 'string'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'merge-1',
|
||||
name: 'Merge',
|
||||
type: 'n8n-nodes-base.merge',
|
||||
typeVersion: 3,
|
||||
position: [650, 300],
|
||||
parameters: {
|
||||
mode: 'append',
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Set 1', type: 'main', index: 0 },
|
||||
{ node: 'Set 2', type: 'main', index: 0 }
|
||||
]
|
||||
]
|
||||
},
|
||||
'Set 1': {
|
||||
main: [[{ node: 'Merge', type: 'main', index: 0 }]]
|
||||
},
|
||||
'Set 2': {
|
||||
main: [[{ node: 'Merge', type: 'main', index: 1 }]]
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Workflow with error handling
|
||||
*
|
||||
* Tests error output configuration and error workflows.
|
||||
*/
|
||||
export const ERROR_HANDLING_WORKFLOW: Partial<Workflow> = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'error-test'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'http-1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4.2,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
url: 'https://httpbin.org/status/500',
|
||||
method: 'GET'
|
||||
},
|
||||
continueOnFail: true,
|
||||
onError: 'continueErrorOutput'
|
||||
},
|
||||
{
|
||||
id: 'set-error',
|
||||
name: 'Handle Error',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [650, 400],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{
|
||||
id: 'error-assign',
|
||||
name: 'error_handled',
|
||||
value: 'true',
|
||||
type: 'boolean'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'HTTP Request', type: 'main', index: 0 }]]
|
||||
},
|
||||
'HTTP Request': {
|
||||
error: [[{ node: 'Handle Error', type: 'main', index: 0 }]]
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* AI Agent workflow (langchain nodes)
|
||||
*
|
||||
* Tests langchain node support.
|
||||
*/
|
||||
export const AI_AGENT_WORKFLOW: Partial<Workflow> = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'manual-1',
|
||||
name: 'When clicking "Test workflow"',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [250, 300],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
typeVersion: 1.7,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
promptType: 'define',
|
||||
text: '={{ $json.input }}',
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'When clicking "Test workflow"': {
|
||||
main: [[{ node: 'AI Agent', type: 'main', index: 0 }]]
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Workflow with n8n expressions
|
||||
*
|
||||
* Tests expression validation.
|
||||
*/
|
||||
export const EXPRESSION_WORKFLOW: Partial<Workflow> = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'manual-1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [250, 300],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'set-1',
|
||||
name: 'Set Variables',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
assignments: {
|
||||
assignments: [
|
||||
{
|
||||
id: 'expr-1',
|
||||
name: 'timestamp',
|
||||
value: '={{ $now }}',
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
id: 'expr-2',
|
||||
name: 'item_count',
|
||||
value: '={{ $json.items.length }}',
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
id: 'expr-3',
|
||||
name: 'first_item',
|
||||
value: '={{ $node["Manual Trigger"].json }}',
|
||||
type: 'object'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Manual Trigger': {
|
||||
main: [[{ node: 'Set Variables', type: 'main', index: 0 }]]
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a fixture by name
|
||||
*
|
||||
* @param name - Fixture name
|
||||
* @returns Workflow fixture
|
||||
*/
|
||||
export function getFixture(
|
||||
name:
|
||||
| 'simple-webhook'
|
||||
| 'simple-http'
|
||||
| 'multi-node'
|
||||
| 'error-handling'
|
||||
| 'ai-agent'
|
||||
| 'expression'
|
||||
): Partial<Workflow> {
|
||||
const fixtures = {
|
||||
'simple-webhook': SIMPLE_WEBHOOK_WORKFLOW,
|
||||
'simple-http': SIMPLE_HTTP_WORKFLOW,
|
||||
'multi-node': MULTI_NODE_WORKFLOW,
|
||||
'error-handling': ERROR_HANDLING_WORKFLOW,
|
||||
'ai-agent': AI_AGENT_WORKFLOW,
|
||||
expression: EXPRESSION_WORKFLOW
|
||||
};
|
||||
|
||||
return JSON.parse(JSON.stringify(fixtures[name])); // Deep clone
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal workflow with custom nodes
|
||||
*
|
||||
* @param nodes - Array of workflow nodes
|
||||
* @param connections - Optional connections object
|
||||
* @returns Workflow fixture
|
||||
*/
|
||||
export function createCustomWorkflow(
|
||||
nodes: WorkflowNode[],
|
||||
connections: Record<string, any> = {}
|
||||
): Partial<Workflow> {
|
||||
return {
|
||||
nodes,
|
||||
connections,
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
}
|
||||
};
|
||||
}
|
||||
63
tests/integration/n8n-api/utils/n8n-client.ts
Normal file
63
tests/integration/n8n-api/utils/n8n-client.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Pre-configured n8n API Client for Integration Tests
|
||||
*
|
||||
* Provides a singleton API client instance configured with test credentials.
|
||||
* Automatically loads credentials from .env (local) or GitHub secrets (CI).
|
||||
*/
|
||||
|
||||
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
||||
import { getN8nCredentials, validateCredentials } from './credentials';
|
||||
|
||||
let client: N8nApiClient | null = null;
|
||||
|
||||
/**
|
||||
* Get or create the test n8n API client
|
||||
*
|
||||
* Creates a singleton instance configured with credentials from
|
||||
* the environment. Validates that required credentials are present.
|
||||
*
|
||||
* @returns Configured N8nApiClient instance
|
||||
* @throws Error if credentials are missing or invalid
|
||||
*
|
||||
* @example
|
||||
* const client = getTestN8nClient();
|
||||
* const workflow = await client.createWorkflow({ ... });
|
||||
*/
|
||||
export function getTestN8nClient(): N8nApiClient {
|
||||
if (!client) {
|
||||
const creds = getN8nCredentials();
|
||||
validateCredentials(creds);
|
||||
client = new N8nApiClient({
|
||||
baseUrl: creds.url,
|
||||
apiKey: creds.apiKey
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the test client instance
|
||||
*
|
||||
* Forces recreation of the client on next call to getTestN8nClient().
|
||||
* Useful for testing or when credentials change.
|
||||
*/
|
||||
export function resetTestN8nClient(): void {
|
||||
client = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the n8n API is accessible
|
||||
*
|
||||
* Performs a health check to verify API connectivity.
|
||||
*
|
||||
* @returns true if API is accessible, false otherwise
|
||||
*/
|
||||
export async function isN8nApiAccessible(): Promise<boolean> {
|
||||
try {
|
||||
const client = getTestN8nClient();
|
||||
await client.healthCheck();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
177
tests/integration/n8n-api/utils/test-context.ts
Normal file
177
tests/integration/n8n-api/utils/test-context.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Test Context for Resource Tracking and Cleanup
|
||||
*
|
||||
* Tracks resources created during tests (workflows, executions) and
|
||||
* provides automatic cleanup functionality.
|
||||
*/
|
||||
|
||||
import { getTestN8nClient } from './n8n-client';
|
||||
import { getN8nCredentials } from './credentials';
|
||||
import { Logger } from '../../../../src/utils/logger';
|
||||
|
||||
const logger = new Logger({ prefix: '[TestContext]' });
|
||||
|
||||
export interface TestContext {
|
||||
/** Workflow IDs created during the test */
|
||||
workflowIds: string[];
|
||||
|
||||
/** Execution IDs created during the test */
|
||||
executionIds: string[];
|
||||
|
||||
/** Clean up all tracked resources */
|
||||
cleanup: () => Promise<void>;
|
||||
|
||||
/** Track a workflow for cleanup */
|
||||
trackWorkflow: (id: string) => void;
|
||||
|
||||
/** Track an execution for cleanup */
|
||||
trackExecution: (id: string) => void;
|
||||
|
||||
/** Remove a workflow from tracking (e.g., already deleted) */
|
||||
untrackWorkflow: (id: string) => void;
|
||||
|
||||
/** Remove an execution from tracking (e.g., already deleted) */
|
||||
untrackExecution: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test context for tracking and cleaning up resources
|
||||
*
|
||||
* Use this in test setup to create a context that tracks all
|
||||
* workflows and executions created during the test. Call cleanup()
|
||||
* in afterEach or afterAll to remove test resources.
|
||||
*
|
||||
* @returns TestContext
|
||||
*
|
||||
* @example
|
||||
* describe('Workflow tests', () => {
|
||||
* let context: TestContext;
|
||||
*
|
||||
* beforeEach(() => {
|
||||
* context = createTestContext();
|
||||
* });
|
||||
*
|
||||
* afterEach(async () => {
|
||||
* await context.cleanup();
|
||||
* });
|
||||
*
|
||||
* it('creates a workflow', async () => {
|
||||
* const workflow = await client.createWorkflow({ ... });
|
||||
* context.trackWorkflow(workflow.id);
|
||||
* // Test runs, then cleanup() automatically deletes the workflow
|
||||
* });
|
||||
* });
|
||||
*/
|
||||
export function createTestContext(): TestContext {
|
||||
const context: TestContext = {
|
||||
workflowIds: [],
|
||||
executionIds: [],
|
||||
|
||||
trackWorkflow(id: string) {
|
||||
if (!this.workflowIds.includes(id)) {
|
||||
this.workflowIds.push(id);
|
||||
logger.debug(`Tracking workflow for cleanup: ${id}`);
|
||||
}
|
||||
},
|
||||
|
||||
trackExecution(id: string) {
|
||||
if (!this.executionIds.includes(id)) {
|
||||
this.executionIds.push(id);
|
||||
logger.debug(`Tracking execution for cleanup: ${id}`);
|
||||
}
|
||||
},
|
||||
|
||||
untrackWorkflow(id: string) {
|
||||
const index = this.workflowIds.indexOf(id);
|
||||
if (index > -1) {
|
||||
this.workflowIds.splice(index, 1);
|
||||
logger.debug(`Untracked workflow: ${id}`);
|
||||
}
|
||||
},
|
||||
|
||||
untrackExecution(id: string) {
|
||||
const index = this.executionIds.indexOf(id);
|
||||
if (index > -1) {
|
||||
this.executionIds.splice(index, 1);
|
||||
logger.debug(`Untracked execution: ${id}`);
|
||||
}
|
||||
},
|
||||
|
||||
async cleanup() {
|
||||
const creds = getN8nCredentials();
|
||||
|
||||
// Skip cleanup if disabled
|
||||
if (!creds.cleanup.enabled) {
|
||||
logger.info('Cleanup disabled, skipping resource cleanup');
|
||||
return;
|
||||
}
|
||||
|
||||
const client = getTestN8nClient();
|
||||
|
||||
// Delete executions first (they reference workflows)
|
||||
if (this.executionIds.length > 0) {
|
||||
logger.info(`Cleaning up ${this.executionIds.length} execution(s)`);
|
||||
|
||||
for (const id of this.executionIds) {
|
||||
try {
|
||||
await client.deleteExecution(id);
|
||||
logger.debug(`Deleted execution: ${id}`);
|
||||
} catch (error) {
|
||||
// Log but don't fail - execution might already be deleted
|
||||
logger.warn(`Failed to delete execution ${id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.executionIds = [];
|
||||
}
|
||||
|
||||
// Then delete workflows
|
||||
if (this.workflowIds.length > 0) {
|
||||
logger.info(`Cleaning up ${this.workflowIds.length} workflow(s)`);
|
||||
|
||||
for (const id of this.workflowIds) {
|
||||
try {
|
||||
await client.deleteWorkflow(id);
|
||||
logger.debug(`Deleted workflow: ${id}`);
|
||||
} catch (error) {
|
||||
// Log but don't fail - workflow might already be deleted
|
||||
logger.warn(`Failed to delete workflow ${id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.workflowIds = [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test workflow name with prefix and timestamp
|
||||
*
|
||||
* Generates a unique workflow name for testing that follows
|
||||
* the configured naming convention.
|
||||
*
|
||||
* @param baseName - Base name for the workflow
|
||||
* @returns Prefixed workflow name with timestamp
|
||||
*
|
||||
* @example
|
||||
* const name = createTestWorkflowName('Simple HTTP Request');
|
||||
* // Returns: "[MCP-TEST] Simple HTTP Request 1704067200000"
|
||||
*/
|
||||
export function createTestWorkflowName(baseName: string): string {
|
||||
const creds = getN8nCredentials();
|
||||
const timestamp = Date.now();
|
||||
return `${creds.cleanup.namePrefix} ${baseName} ${timestamp}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured test tag
|
||||
*
|
||||
* @returns Tag to apply to test workflows
|
||||
*/
|
||||
export function getTestTag(): string {
|
||||
const creds = getN8nCredentials();
|
||||
return creds.cleanup.tag;
|
||||
}
|
||||
289
tests/integration/n8n-api/utils/webhook-workflows.ts
Normal file
289
tests/integration/n8n-api/utils/webhook-workflows.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Webhook Workflow Configuration
|
||||
*
|
||||
* Provides configuration and setup instructions for webhook workflows
|
||||
* required for integration testing.
|
||||
*
|
||||
* These workflows must be created manually in n8n and activated because
|
||||
* the n8n API doesn't support workflow activation.
|
||||
*/
|
||||
|
||||
import { Workflow, WorkflowNode } from '../../../../src/types/n8n-api';
|
||||
|
||||
export interface WebhookWorkflowConfig {
|
||||
name: string;
|
||||
description: string;
|
||||
httpMethod: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
path: string;
|
||||
nodes: Array<Partial<WorkflowNode>>;
|
||||
connections: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for required webhook workflows
|
||||
*/
|
||||
export const WEBHOOK_WORKFLOW_CONFIGS: Record<string, WebhookWorkflowConfig> = {
|
||||
GET: {
|
||||
name: '[MCP-TEST] Webhook GET',
|
||||
description: 'Pre-activated webhook for GET method testing',
|
||||
httpMethod: 'GET',
|
||||
path: 'mcp-test-get',
|
||||
nodes: [
|
||||
{
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'GET',
|
||||
path: 'mcp-test-get',
|
||||
responseMode: 'lastNode',
|
||||
options: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Respond to Webhook',
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
typeVersion: 1.1,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'Respond to Webhook', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
},
|
||||
POST: {
|
||||
name: '[MCP-TEST] Webhook POST',
|
||||
description: 'Pre-activated webhook for POST method testing',
|
||||
httpMethod: 'POST',
|
||||
path: 'mcp-test-post',
|
||||
nodes: [
|
||||
{
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'POST',
|
||||
path: 'mcp-test-post',
|
||||
responseMode: 'lastNode',
|
||||
options: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Respond to Webhook',
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
typeVersion: 1.1,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'Respond to Webhook', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
},
|
||||
PUT: {
|
||||
name: '[MCP-TEST] Webhook PUT',
|
||||
description: 'Pre-activated webhook for PUT method testing',
|
||||
httpMethod: 'PUT',
|
||||
path: 'mcp-test-put',
|
||||
nodes: [
|
||||
{
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'PUT',
|
||||
path: 'mcp-test-put',
|
||||
responseMode: 'lastNode',
|
||||
options: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Respond to Webhook',
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
typeVersion: 1.1,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'Respond to Webhook', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
},
|
||||
DELETE: {
|
||||
name: '[MCP-TEST] Webhook DELETE',
|
||||
description: 'Pre-activated webhook for DELETE method testing',
|
||||
httpMethod: 'DELETE',
|
||||
path: 'mcp-test-delete',
|
||||
nodes: [
|
||||
{
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300],
|
||||
parameters: {
|
||||
httpMethod: 'DELETE',
|
||||
path: 'mcp-test-delete',
|
||||
responseMode: 'lastNode',
|
||||
options: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Respond to Webhook',
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
typeVersion: 1.1,
|
||||
position: [450, 300],
|
||||
parameters: {
|
||||
options: {}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
Webhook: {
|
||||
main: [[{ node: 'Respond to Webhook', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Print setup instructions for webhook workflows
|
||||
*/
|
||||
export function printSetupInstructions(): void {
|
||||
console.log(`
|
||||
╔════════════════════════════════════════════════════════════════╗
|
||||
║ WEBHOOK WORKFLOW SETUP REQUIRED ║
|
||||
╠════════════════════════════════════════════════════════════════╣
|
||||
║ ║
|
||||
║ Integration tests require 4 pre-activated webhook workflows: ║
|
||||
║ ║
|
||||
║ 1. Create workflows manually in n8n UI ║
|
||||
║ 2. Use the configurations shown below ║
|
||||
║ 3. ACTIVATE each workflow in n8n UI ║
|
||||
║ 4. Copy workflow IDs to .env file ║
|
||||
║ ║
|
||||
╚════════════════════════════════════════════════════════════════╝
|
||||
|
||||
Required workflows:
|
||||
`);
|
||||
|
||||
Object.entries(WEBHOOK_WORKFLOW_CONFIGS).forEach(([method, config]) => {
|
||||
console.log(`
|
||||
${method} Method:
|
||||
Name: ${config.name}
|
||||
Path: ${config.path}
|
||||
.env variable: N8N_TEST_WEBHOOK_${method}_ID
|
||||
|
||||
Workflow Structure:
|
||||
1. Webhook node (${method} method, path: ${config.path})
|
||||
2. Respond to Webhook node
|
||||
|
||||
After creating:
|
||||
1. Save the workflow
|
||||
2. ACTIVATE the workflow (toggle in UI)
|
||||
3. Copy the workflow ID
|
||||
4. Add to .env: N8N_TEST_WEBHOOK_${method}_ID=<workflow-id>
|
||||
`);
|
||||
});
|
||||
|
||||
console.log(`
|
||||
See docs/local/integration-testing-plan.md for detailed instructions.
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate workflow JSON for a webhook workflow
|
||||
*
|
||||
* @param method - HTTP method
|
||||
* @returns Partial workflow ready to create
|
||||
*/
|
||||
export function generateWebhookWorkflowJson(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
): Partial<Workflow> {
|
||||
const config = WEBHOOK_WORKFLOW_CONFIGS[method];
|
||||
|
||||
return {
|
||||
name: config.name,
|
||||
nodes: config.nodes as any,
|
||||
connections: config.connections,
|
||||
active: false, // Will need to be activated manually
|
||||
settings: {
|
||||
executionOrder: 'v1'
|
||||
},
|
||||
tags: ['mcp-integration-test', 'webhook-test']
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all webhook workflow JSONs
|
||||
*
|
||||
* Returns an object with all 4 webhook workflow configurations
|
||||
* ready to be created in n8n.
|
||||
*
|
||||
* @returns Object with workflow configurations
|
||||
*/
|
||||
export function exportAllWebhookWorkflows(): Record<string, Partial<Workflow>> {
|
||||
return {
|
||||
GET: generateWebhookWorkflowJson('GET'),
|
||||
POST: generateWebhookWorkflowJson('POST'),
|
||||
PUT: generateWebhookWorkflowJson('PUT'),
|
||||
DELETE: generateWebhookWorkflowJson('DELETE')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get webhook URL for a given n8n instance and HTTP method
|
||||
*
|
||||
* @param n8nUrl - n8n instance URL
|
||||
* @param method - HTTP method
|
||||
* @returns Webhook URL
|
||||
*/
|
||||
export function getWebhookUrl(
|
||||
n8nUrl: string,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
): string {
|
||||
const config = WEBHOOK_WORKFLOW_CONFIGS[method];
|
||||
const baseUrl = n8nUrl.replace(/\/$/, ''); // Remove trailing slash
|
||||
return `${baseUrl}/webhook/${config.path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate webhook workflow structure
|
||||
*
|
||||
* Checks if a workflow matches the expected webhook workflow structure.
|
||||
*
|
||||
* @param workflow - Workflow to validate
|
||||
* @param method - Expected HTTP method
|
||||
* @returns true if valid
|
||||
*/
|
||||
export function isValidWebhookWorkflow(
|
||||
workflow: Partial<Workflow>,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
): boolean {
|
||||
if (!workflow.nodes || workflow.nodes.length < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const webhookNode = workflow.nodes.find(n => n.type === 'n8n-nodes-base.webhook');
|
||||
if (!webhookNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const params = webhookNode.parameters as any;
|
||||
return params.httpMethod === method;
|
||||
}
|
||||
@@ -142,7 +142,8 @@ describe.skip('MCP Telemetry Integration', () => {
|
||||
telemetry.trackError(
|
||||
error.constructor.name,
|
||||
error.message,
|
||||
toolName
|
||||
toolName,
|
||||
error.message
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
313
tests/integration/workflow-creation-node-type-format.test.ts
Normal file
313
tests/integration/workflow-creation-node-type-format.test.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Integration test for workflow creation with node type format validation
|
||||
*
|
||||
* This test validates that workflows are correctly validated with FULL form node types
|
||||
* (n8n-nodes-base.*) as required by the n8n API, without normalization to SHORT form.
|
||||
*
|
||||
* Background: Bug in handlers-n8n-manager.ts was normalizing node types to SHORT form
|
||||
* (nodes-base.*) before validation, causing validation to reject all workflows.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateWorkflowStructure } from '@/services/n8n-validation';
|
||||
|
||||
describe('Workflow Creation Node Type Format (Integration)', () => {
|
||||
describe('validateWorkflowStructure with FULL form node types', () => {
|
||||
it('should accept workflows with FULL form node types (n8n-nodes-base.*)', () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'manual-1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger', // FULL form
|
||||
typeVersion: 1,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'set-1',
|
||||
name: 'Set Data',
|
||||
type: 'n8n-nodes-base.set', // FULL form
|
||||
typeVersion: 3.4,
|
||||
position: [450, 300] as [number, number],
|
||||
parameters: {
|
||||
mode: 'manual',
|
||||
assignments: {
|
||||
assignments: [{
|
||||
id: '1',
|
||||
name: 'test',
|
||||
value: 'hello',
|
||||
type: 'string'
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Manual Trigger': {
|
||||
main: [[{
|
||||
node: 'Set Data',
|
||||
type: 'main',
|
||||
index: 0
|
||||
}]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const errors = validateWorkflowStructure(workflow);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should reject workflows with SHORT form node types (nodes-base.*)', () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'manual-1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'nodes-base.manualTrigger', // SHORT form - should be rejected
|
||||
typeVersion: 1,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const errors = validateWorkflowStructure(workflow);
|
||||
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e =>
|
||||
e.includes('Invalid node type "nodes-base.manualTrigger"') &&
|
||||
e.includes('Use "n8n-nodes-base.manualTrigger" instead')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept workflows with LangChain nodes in FULL form', () => {
|
||||
const workflow = {
|
||||
name: 'AI Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'manual-1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent', // FULL form
|
||||
typeVersion: 1,
|
||||
position: [450, 300] as [number, number],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Manual Trigger': {
|
||||
main: [[{
|
||||
node: 'AI Agent',
|
||||
type: 'main',
|
||||
index: 0
|
||||
}]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const errors = validateWorkflowStructure(workflow);
|
||||
|
||||
// Should accept FULL form LangChain nodes
|
||||
// Note: May have other validation errors (missing parameters), but NOT node type errors
|
||||
const hasNodeTypeError = errors.some(e =>
|
||||
e.includes('Invalid node type') && e.includes('@n8n/n8n-nodes-langchain.agent')
|
||||
);
|
||||
expect(hasNodeTypeError).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject node types without package prefix', () => {
|
||||
const workflow = {
|
||||
name: 'Invalid Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node-1',
|
||||
name: 'Invalid Node',
|
||||
type: 'webhook', // No package prefix
|
||||
typeVersion: 1,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const errors = validateWorkflowStructure(workflow);
|
||||
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors.some(e =>
|
||||
e.includes('Invalid node type "webhook"') &&
|
||||
e.includes('must include package prefix')
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-world workflow examples', () => {
|
||||
it('should validate webhook workflow correctly', () => {
|
||||
const workflow = {
|
||||
name: 'Webhook to HTTP',
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
path: 'test-webhook',
|
||||
httpMethod: 'POST',
|
||||
responseMode: 'onReceived'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'http-1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4.2,
|
||||
position: [450, 300] as [number, number],
|
||||
parameters: {
|
||||
method: 'POST',
|
||||
url: 'https://example.com/api',
|
||||
sendBody: true,
|
||||
bodyParameters: {
|
||||
parameters: []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Webhook': {
|
||||
main: [[{
|
||||
node: 'HTTP Request',
|
||||
type: 'main',
|
||||
index: 0
|
||||
}]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const errors = validateWorkflowStructure(workflow);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should validate schedule trigger workflow correctly', () => {
|
||||
const workflow = {
|
||||
name: 'Daily Report',
|
||||
nodes: [
|
||||
{
|
||||
id: 'schedule-1',
|
||||
name: 'Schedule Trigger',
|
||||
type: 'n8n-nodes-base.scheduleTrigger',
|
||||
typeVersion: 1.2,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {
|
||||
rule: {
|
||||
interval: [{
|
||||
field: 'days',
|
||||
daysInterval: 1
|
||||
}]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'set-1',
|
||||
name: 'Set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [450, 300] as [number, number],
|
||||
parameters: {
|
||||
mode: 'manual',
|
||||
assignments: {
|
||||
assignments: []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Schedule Trigger': {
|
||||
main: [[{
|
||||
node: 'Set',
|
||||
type: 'main',
|
||||
index: 0
|
||||
}]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const errors = validateWorkflowStructure(workflow);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regression test for normalization bug', () => {
|
||||
it('should NOT normalize node types before validation', () => {
|
||||
// This test ensures that handleCreateWorkflow does NOT call
|
||||
// NodeTypeNormalizer.normalizeWorkflowNodeTypes() before validation
|
||||
|
||||
const fullFormWorkflow = {
|
||||
name: 'Test',
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Set',
|
||||
type: 'n8n-nodes-base.set',
|
||||
typeVersion: 3.4,
|
||||
position: [200, 0] as [number, number],
|
||||
parameters: {
|
||||
mode: 'manual',
|
||||
assignments: { assignments: [] }
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'Manual Trigger': {
|
||||
main: [[{ node: 'Set', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const errors = validateWorkflowStructure(fullFormWorkflow);
|
||||
|
||||
// FULL form should pass validation
|
||||
expect(errors).toEqual([]);
|
||||
|
||||
// SHORT form (what normalizer produces) should FAIL validation
|
||||
const shortFormWorkflow = {
|
||||
...fullFormWorkflow,
|
||||
nodes: fullFormWorkflow.nodes.map(node => ({
|
||||
...node,
|
||||
type: node.type.replace('n8n-nodes-base.', 'nodes-base.') // Convert to SHORT form
|
||||
}))
|
||||
};
|
||||
|
||||
const shortFormErrors = validateWorkflowStructure(shortFormWorkflow);
|
||||
|
||||
expect(shortFormErrors.length).toBeGreaterThan(0);
|
||||
expect(shortFormErrors.some(e =>
|
||||
e.includes('Invalid node type') &&
|
||||
e.includes('nodes-base.')
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
434
tests/unit/mcp/get-node-essentials-examples.test.ts
Normal file
434
tests/unit/mcp/get-node-essentials-examples.test.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { N8NDocumentationMCPServer } from '../../../src/mcp/server';
|
||||
|
||||
/**
|
||||
* Unit tests for get_node_essentials with includeExamples parameter
|
||||
* Testing P0-R3 feature: Template-based configuration examples with metadata
|
||||
*/
|
||||
|
||||
describe('get_node_essentials with includeExamples', () => {
|
||||
let server: N8NDocumentationMCPServer;
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.NODE_DB_PATH = ':memory:';
|
||||
server = new N8NDocumentationMCPServer();
|
||||
await (server as any).initialized;
|
||||
|
||||
// Populate in-memory database with test nodes
|
||||
// NOTE: Database stores nodes in SHORT form (nodes-base.xxx, not n8n-nodes-base.xxx)
|
||||
const testNodes = [
|
||||
{
|
||||
node_type: 'nodes-base.httpRequest',
|
||||
package_name: 'n8n-nodes-base',
|
||||
display_name: 'HTTP Request',
|
||||
description: 'Makes an HTTP request',
|
||||
category: 'Core Nodes',
|
||||
is_ai_tool: 0,
|
||||
is_trigger: 0,
|
||||
is_webhook: 0,
|
||||
is_versioned: 1,
|
||||
version: '1',
|
||||
properties_schema: JSON.stringify([]),
|
||||
operations: JSON.stringify([])
|
||||
},
|
||||
{
|
||||
node_type: 'nodes-base.webhook',
|
||||
package_name: 'n8n-nodes-base',
|
||||
display_name: 'Webhook',
|
||||
description: 'Starts workflow on webhook call',
|
||||
category: 'Core Nodes',
|
||||
is_ai_tool: 0,
|
||||
is_trigger: 1,
|
||||
is_webhook: 1,
|
||||
is_versioned: 1,
|
||||
version: '1',
|
||||
properties_schema: JSON.stringify([]),
|
||||
operations: JSON.stringify([])
|
||||
},
|
||||
{
|
||||
node_type: 'nodes-base.test',
|
||||
package_name: 'n8n-nodes-base',
|
||||
display_name: 'Test Node',
|
||||
description: 'Test node for examples',
|
||||
category: 'Core Nodes',
|
||||
is_ai_tool: 0,
|
||||
is_trigger: 0,
|
||||
is_webhook: 0,
|
||||
is_versioned: 1,
|
||||
version: '1',
|
||||
properties_schema: JSON.stringify([]),
|
||||
operations: JSON.stringify([])
|
||||
}
|
||||
];
|
||||
|
||||
// Insert test nodes into the in-memory database
|
||||
const db = (server as any).db;
|
||||
if (db) {
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO nodes (
|
||||
node_type, package_name, display_name, description, category,
|
||||
is_ai_tool, is_trigger, is_webhook, is_versioned, version,
|
||||
properties_schema, operations
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const node of testNodes) {
|
||||
insertStmt.run(
|
||||
node.node_type,
|
||||
node.package_name,
|
||||
node.display_name,
|
||||
node.description,
|
||||
node.category,
|
||||
node.is_ai_tool,
|
||||
node.is_trigger,
|
||||
node.is_webhook,
|
||||
node.is_versioned,
|
||||
node.version,
|
||||
node.properties_schema,
|
||||
node.operations
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NODE_DB_PATH;
|
||||
});
|
||||
|
||||
describe('includeExamples parameter', () => {
|
||||
it('should not include examples when includeExamples is false', async () => {
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.httpRequest', false);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.examples).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not include examples when includeExamples is undefined', async () => {
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.httpRequest', undefined);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.examples).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include examples when includeExamples is true', async () => {
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.httpRequest', true);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
// Note: In-memory test database may not have template configs
|
||||
// This test validates the parameter is processed correctly
|
||||
});
|
||||
|
||||
it('should limit examples to top 3 per node', async () => {
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.webhook', true);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
if (result.examples) {
|
||||
expect(result.examples.length).toBeLessThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('example data structure with metadata', () => {
|
||||
it('should return examples with full metadata structure', async () => {
|
||||
// Mock database to return example data with metadata
|
||||
const mockDb = (server as any).db;
|
||||
if (mockDb) {
|
||||
const originalPrepare = mockDb.prepare.bind(mockDb);
|
||||
mockDb.prepare = vi.fn((query: string) => {
|
||||
if (query.includes('template_node_configs')) {
|
||||
return {
|
||||
all: vi.fn(() => [
|
||||
{
|
||||
parameters_json: JSON.stringify({
|
||||
httpMethod: 'POST',
|
||||
path: 'webhook-test',
|
||||
responseMode: 'lastNode'
|
||||
}),
|
||||
template_name: 'Webhook Template',
|
||||
template_views: 2000,
|
||||
complexity: 'simple',
|
||||
use_cases: JSON.stringify(['webhook processing', 'API integration']),
|
||||
has_credentials: 0,
|
||||
has_expressions: 1
|
||||
}
|
||||
])
|
||||
};
|
||||
}
|
||||
return originalPrepare(query);
|
||||
});
|
||||
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.webhook', true);
|
||||
|
||||
if (result.examples && result.examples.length > 0) {
|
||||
const example = result.examples[0];
|
||||
|
||||
// Verify structure
|
||||
expect(example).toHaveProperty('configuration');
|
||||
expect(example).toHaveProperty('source');
|
||||
expect(example).toHaveProperty('useCases');
|
||||
expect(example).toHaveProperty('metadata');
|
||||
|
||||
// Verify source structure
|
||||
expect(example.source).toHaveProperty('template');
|
||||
expect(example.source).toHaveProperty('views');
|
||||
expect(example.source).toHaveProperty('complexity');
|
||||
|
||||
// Verify metadata structure
|
||||
expect(example.metadata).toHaveProperty('hasCredentials');
|
||||
expect(example.metadata).toHaveProperty('hasExpressions');
|
||||
|
||||
// Verify types
|
||||
expect(typeof example.configuration).toBe('object');
|
||||
expect(typeof example.source.template).toBe('string');
|
||||
expect(typeof example.source.views).toBe('number');
|
||||
expect(typeof example.source.complexity).toBe('string');
|
||||
expect(Array.isArray(example.useCases)).toBe(true);
|
||||
expect(typeof example.metadata.hasCredentials).toBe('boolean');
|
||||
expect(typeof example.metadata.hasExpressions).toBe('boolean');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should include complexity in source metadata', async () => {
|
||||
const mockDb = (server as any).db;
|
||||
if (mockDb) {
|
||||
const originalPrepare = mockDb.prepare.bind(mockDb);
|
||||
mockDb.prepare = vi.fn((query: string) => {
|
||||
if (query.includes('template_node_configs')) {
|
||||
return {
|
||||
all: vi.fn(() => [
|
||||
{
|
||||
parameters_json: JSON.stringify({ url: 'https://api.example.com' }),
|
||||
template_name: 'Simple HTTP Request',
|
||||
template_views: 500,
|
||||
complexity: 'simple',
|
||||
use_cases: JSON.stringify([]),
|
||||
has_credentials: 0,
|
||||
has_expressions: 0
|
||||
},
|
||||
{
|
||||
parameters_json: JSON.stringify({
|
||||
url: '={{ $json.url }}',
|
||||
options: { timeout: 30000 }
|
||||
}),
|
||||
template_name: 'Complex HTTP Request',
|
||||
template_views: 300,
|
||||
complexity: 'complex',
|
||||
use_cases: JSON.stringify(['advanced API calls']),
|
||||
has_credentials: 1,
|
||||
has_expressions: 1
|
||||
}
|
||||
])
|
||||
};
|
||||
}
|
||||
return originalPrepare(query);
|
||||
});
|
||||
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.httpRequest', true);
|
||||
|
||||
if (result.examples && result.examples.length >= 2) {
|
||||
expect(result.examples[0].source.complexity).toBe('simple');
|
||||
expect(result.examples[1].source.complexity).toBe('complex');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should limit use cases to 2 items', async () => {
|
||||
const mockDb = (server as any).db;
|
||||
if (mockDb) {
|
||||
const originalPrepare = mockDb.prepare.bind(mockDb);
|
||||
mockDb.prepare = vi.fn((query: string) => {
|
||||
if (query.includes('template_node_configs')) {
|
||||
return {
|
||||
all: vi.fn(() => [
|
||||
{
|
||||
parameters_json: JSON.stringify({}),
|
||||
template_name: 'Test Template',
|
||||
template_views: 100,
|
||||
complexity: 'medium',
|
||||
use_cases: JSON.stringify([
|
||||
'use case 1',
|
||||
'use case 2',
|
||||
'use case 3',
|
||||
'use case 4'
|
||||
]),
|
||||
has_credentials: 0,
|
||||
has_expressions: 0
|
||||
}
|
||||
])
|
||||
};
|
||||
}
|
||||
return originalPrepare(query);
|
||||
});
|
||||
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.test', true);
|
||||
|
||||
if (result.examples && result.examples.length > 0) {
|
||||
expect(result.examples[0].useCases.length).toBeLessThanOrEqual(2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle empty use_cases gracefully', async () => {
|
||||
const mockDb = (server as any).db;
|
||||
if (mockDb) {
|
||||
const originalPrepare = mockDb.prepare.bind(mockDb);
|
||||
mockDb.prepare = vi.fn((query: string) => {
|
||||
if (query.includes('template_node_configs')) {
|
||||
return {
|
||||
all: vi.fn(() => [
|
||||
{
|
||||
parameters_json: JSON.stringify({}),
|
||||
template_name: 'Test Template',
|
||||
template_views: 100,
|
||||
complexity: 'medium',
|
||||
use_cases: null,
|
||||
has_credentials: 0,
|
||||
has_expressions: 0
|
||||
}
|
||||
])
|
||||
};
|
||||
}
|
||||
return originalPrepare(query);
|
||||
});
|
||||
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.test', true);
|
||||
|
||||
if (result.examples && result.examples.length > 0) {
|
||||
expect(result.examples[0].useCases).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('caching behavior with includeExamples', () => {
|
||||
it('should use different cache keys for with/without examples', async () => {
|
||||
const cache = (server as any).cache;
|
||||
const cacheGetSpy = vi.spyOn(cache, 'get');
|
||||
|
||||
// First call without examples
|
||||
await (server as any).getNodeEssentials('nodes-base.httpRequest', false);
|
||||
expect(cacheGetSpy).toHaveBeenCalledWith(expect.stringContaining('basic'));
|
||||
|
||||
// Second call with examples
|
||||
await (server as any).getNodeEssentials('nodes-base.httpRequest', true);
|
||||
expect(cacheGetSpy).toHaveBeenCalledWith(expect.stringContaining('withExamples'));
|
||||
});
|
||||
|
||||
it('should cache results separately for different includeExamples values', async () => {
|
||||
// Call with examples
|
||||
const resultWithExamples1 = await (server as any).getNodeEssentials('nodes-base.httpRequest', true);
|
||||
|
||||
// Call without examples
|
||||
const resultWithoutExamples = await (server as any).getNodeEssentials('nodes-base.httpRequest', false);
|
||||
|
||||
// Call with examples again (should be cached)
|
||||
const resultWithExamples2 = await (server as any).getNodeEssentials('nodes-base.httpRequest', true);
|
||||
|
||||
// Results with examples should match
|
||||
expect(resultWithExamples1).toEqual(resultWithExamples2);
|
||||
|
||||
// Result without examples should not have examples
|
||||
expect(resultWithoutExamples.examples).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('backward compatibility', () => {
|
||||
it('should maintain backward compatibility when includeExamples not specified', async () => {
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.httpRequest');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.nodeType).toBeDefined();
|
||||
expect(result.displayName).toBeDefined();
|
||||
expect(result.examples).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return same core data regardless of includeExamples value', async () => {
|
||||
const resultWithout = await (server as any).getNodeEssentials('nodes-base.httpRequest', false);
|
||||
const resultWith = await (server as any).getNodeEssentials('nodes-base.httpRequest', true);
|
||||
|
||||
// Core fields should be identical
|
||||
expect(resultWithout.nodeType).toBe(resultWith.nodeType);
|
||||
expect(resultWithout.displayName).toBe(resultWith.displayName);
|
||||
expect(resultWithout.description).toBe(resultWith.description);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should continue to work even if example fetch fails', async () => {
|
||||
const mockDb = (server as any).db;
|
||||
if (mockDb) {
|
||||
const originalPrepare = mockDb.prepare.bind(mockDb);
|
||||
mockDb.prepare = vi.fn((query: string) => {
|
||||
if (query.includes('template_node_configs')) {
|
||||
throw new Error('Database error');
|
||||
}
|
||||
return originalPrepare(query);
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.webhook', true);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.nodeType).toBeDefined();
|
||||
// Examples should be empty array due to error (fallback behavior)
|
||||
expect(result.examples).toEqual([]);
|
||||
expect(result.examplesCount).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle malformed JSON in template configs gracefully', async () => {
|
||||
const mockDb = (server as any).db;
|
||||
if (mockDb) {
|
||||
const originalPrepare = mockDb.prepare.bind(mockDb);
|
||||
mockDb.prepare = vi.fn((query: string) => {
|
||||
if (query.includes('template_node_configs')) {
|
||||
return {
|
||||
all: vi.fn(() => [
|
||||
{
|
||||
parameters_json: 'invalid json',
|
||||
template_name: 'Test',
|
||||
template_views: 100,
|
||||
complexity: 'medium',
|
||||
use_cases: 'also invalid',
|
||||
has_credentials: 0,
|
||||
has_expressions: 0
|
||||
}
|
||||
])
|
||||
};
|
||||
}
|
||||
return originalPrepare(query);
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
const result = await (server as any).getNodeEssentials('nodes-base.test', true);
|
||||
expect(result).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance', () => {
|
||||
it('should complete in reasonable time with examples', async () => {
|
||||
const start = Date.now();
|
||||
await (server as any).getNodeEssentials('nodes-base.httpRequest', true);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
// Should complete under 100ms
|
||||
expect(duration).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it('should not add significant overhead when includeExamples is false', async () => {
|
||||
const startWithout = Date.now();
|
||||
await (server as any).getNodeEssentials('nodes-base.httpRequest', false);
|
||||
const durationWithout = Date.now() - startWithout;
|
||||
|
||||
const startWith = Date.now();
|
||||
await (server as any).getNodeEssentials('nodes-base.httpRequest', true);
|
||||
const durationWith = Date.now() - startWith;
|
||||
|
||||
// Both should be fast
|
||||
expect(durationWithout).toBeLessThan(50);
|
||||
expect(durationWith).toBeLessThan(100);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -230,6 +230,8 @@ describe('handlers-n8n-manager', () => {
|
||||
data: testWorkflow,
|
||||
message: 'Workflow "Test Workflow" created successfully with ID: test-workflow-id',
|
||||
});
|
||||
|
||||
// Should send input as-is to API (n8n expects FULL form: n8n-nodes-base.*)
|
||||
expect(mockApiClient.createWorkflow).toHaveBeenCalledWith(input);
|
||||
expect(n8nValidation.validateWorkflowStructure).toHaveBeenCalledWith(input);
|
||||
});
|
||||
@@ -267,9 +269,9 @@ describe('handlers-n8n-manager', () => {
|
||||
it('should handle API errors', async () => {
|
||||
const input = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [{
|
||||
id: 'node1',
|
||||
name: 'Start',
|
||||
nodes: [{
|
||||
id: 'node1',
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.start',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
@@ -304,6 +306,326 @@ describe('handlers-n8n-manager', () => {
|
||||
error: 'n8n API not configured. Please set N8N_API_URL and N8N_API_KEY environment variables.',
|
||||
});
|
||||
});
|
||||
|
||||
describe('SHORT form detection', () => {
|
||||
it('should detect and reject nodes-base.* SHORT form', async () => {
|
||||
const input = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [{
|
||||
id: 'node1',
|
||||
name: 'Webhook',
|
||||
type: 'nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
}],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Node type format error: n8n API requires FULL form node types');
|
||||
expect(result.details.errors).toHaveLength(1);
|
||||
expect(result.details.errors[0]).toContain('Node 0');
|
||||
expect(result.details.errors[0]).toContain('Webhook');
|
||||
expect(result.details.errors[0]).toContain('nodes-base.webhook');
|
||||
expect(result.details.errors[0]).toContain('n8n-nodes-base.webhook');
|
||||
expect(result.details.errors[0]).toContain('SHORT form');
|
||||
expect(result.details.errors[0]).toContain('FULL form');
|
||||
expect(result.details.hint).toBe('Use n8n-nodes-base.* instead of nodes-base.* for standard nodes');
|
||||
});
|
||||
|
||||
it('should detect and reject nodes-langchain.* SHORT form', async () => {
|
||||
const input = {
|
||||
name: 'AI Workflow',
|
||||
nodes: [{
|
||||
id: 'ai1',
|
||||
name: 'AI Agent',
|
||||
type: 'nodes-langchain.agent',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
}],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Node type format error: n8n API requires FULL form node types');
|
||||
expect(result.details.errors).toHaveLength(1);
|
||||
expect(result.details.errors[0]).toContain('Node 0');
|
||||
expect(result.details.errors[0]).toContain('AI Agent');
|
||||
expect(result.details.errors[0]).toContain('nodes-langchain.agent');
|
||||
expect(result.details.errors[0]).toContain('@n8n/n8n-nodes-langchain.agent');
|
||||
expect(result.details.errors[0]).toContain('SHORT form');
|
||||
expect(result.details.errors[0]).toContain('FULL form');
|
||||
expect(result.details.hint).toBe('Use n8n-nodes-base.* instead of nodes-base.* for standard nodes');
|
||||
});
|
||||
|
||||
it('should detect multiple SHORT form nodes', async () => {
|
||||
const input = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'Webhook',
|
||||
type: 'nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'node2',
|
||||
name: 'HTTP Request',
|
||||
type: 'nodes-base.httpRequest',
|
||||
typeVersion: 1,
|
||||
position: [200, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'node3',
|
||||
name: 'AI Agent',
|
||||
type: 'nodes-langchain.agent',
|
||||
typeVersion: 1,
|
||||
position: [300, 100],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Node type format error: n8n API requires FULL form node types');
|
||||
expect(result.details.errors).toHaveLength(3);
|
||||
expect(result.details.errors[0]).toContain('Node 0');
|
||||
expect(result.details.errors[0]).toContain('Webhook');
|
||||
expect(result.details.errors[0]).toContain('n8n-nodes-base.webhook');
|
||||
expect(result.details.errors[1]).toContain('Node 1');
|
||||
expect(result.details.errors[1]).toContain('HTTP Request');
|
||||
expect(result.details.errors[1]).toContain('n8n-nodes-base.httpRequest');
|
||||
expect(result.details.errors[2]).toContain('Node 2');
|
||||
expect(result.details.errors[2]).toContain('AI Agent');
|
||||
expect(result.details.errors[2]).toContain('@n8n/n8n-nodes-langchain.agent');
|
||||
});
|
||||
|
||||
it('should allow FULL form n8n-nodes-base.* without error', async () => {
|
||||
const testWorkflow = createTestWorkflow({
|
||||
nodes: [{
|
||||
id: 'node1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
}]
|
||||
});
|
||||
|
||||
const input = {
|
||||
name: 'Test Workflow',
|
||||
nodes: testWorkflow.nodes,
|
||||
connections: {}
|
||||
};
|
||||
|
||||
mockApiClient.createWorkflow.mockResolvedValue(testWorkflow);
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockApiClient.createWorkflow).toHaveBeenCalledWith(input);
|
||||
});
|
||||
|
||||
it('should allow FULL form @n8n/n8n-nodes-langchain.* without error', async () => {
|
||||
const testWorkflow = createTestWorkflow({
|
||||
nodes: [{
|
||||
id: 'ai1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
}]
|
||||
});
|
||||
|
||||
const input = {
|
||||
name: 'AI Workflow',
|
||||
nodes: testWorkflow.nodes,
|
||||
connections: {}
|
||||
};
|
||||
|
||||
mockApiClient.createWorkflow.mockResolvedValue(testWorkflow);
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockApiClient.createWorkflow).toHaveBeenCalledWith(input);
|
||||
});
|
||||
|
||||
it('should detect SHORT form in mixed FULL/SHORT workflow', async () => {
|
||||
const input = {
|
||||
name: 'Mixed Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.start', // FULL form - correct
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'node2',
|
||||
name: 'Webhook',
|
||||
type: 'nodes-base.webhook', // SHORT form - error
|
||||
typeVersion: 1,
|
||||
position: [200, 100],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Node type format error: n8n API requires FULL form node types');
|
||||
expect(result.details.errors).toHaveLength(1);
|
||||
expect(result.details.errors[0]).toContain('Node 1');
|
||||
expect(result.details.errors[0]).toContain('Webhook');
|
||||
expect(result.details.errors[0]).toContain('nodes-base.webhook');
|
||||
});
|
||||
|
||||
it('should handle nodes with null type gracefully', async () => {
|
||||
const input = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [{
|
||||
id: 'node1',
|
||||
name: 'Unknown',
|
||||
type: null,
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
}],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
// Should pass SHORT form detection (null doesn't start with 'nodes-base.')
|
||||
// Will fail at structure validation or API call
|
||||
vi.mocked(n8nValidation.validateWorkflowStructure).mockReturnValue([
|
||||
'Node type is required'
|
||||
]);
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
// Should fail at validation, not SHORT form detection
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Workflow validation failed');
|
||||
});
|
||||
|
||||
it('should handle nodes with undefined type gracefully', async () => {
|
||||
const input = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [{
|
||||
id: 'node1',
|
||||
name: 'Unknown',
|
||||
// type is undefined
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
}],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
// Should pass SHORT form detection (undefined doesn't start with 'nodes-base.')
|
||||
// Will fail at structure validation or API call
|
||||
vi.mocked(n8nValidation.validateWorkflowStructure).mockReturnValue([
|
||||
'Node type is required'
|
||||
]);
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
// Should fail at validation, not SHORT form detection
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Workflow validation failed');
|
||||
});
|
||||
|
||||
it('should handle empty nodes array gracefully', async () => {
|
||||
const input = {
|
||||
name: 'Empty Workflow',
|
||||
nodes: [],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
// Should pass SHORT form detection (no nodes to check)
|
||||
vi.mocked(n8nValidation.validateWorkflowStructure).mockReturnValue([
|
||||
'Workflow must have at least one node'
|
||||
]);
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
// Should fail at validation, not SHORT form detection
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Workflow validation failed');
|
||||
});
|
||||
|
||||
it('should handle nodes array with undefined nodes gracefully', async () => {
|
||||
const input = {
|
||||
name: 'Test Workflow',
|
||||
nodes: undefined,
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
// Should fail at Zod validation (nodes is required in schema)
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid input');
|
||||
expect(result.details).toHaveProperty('errors');
|
||||
});
|
||||
|
||||
it('should provide correct index in error message for multiple nodes', async () => {
|
||||
const input = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.start', // FULL form - OK
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'node2',
|
||||
name: 'Process',
|
||||
type: 'n8n-nodes-base.set', // FULL form - OK
|
||||
typeVersion: 1,
|
||||
position: [200, 100],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: 'node3',
|
||||
name: 'Webhook',
|
||||
type: 'nodes-base.webhook', // SHORT form - index 2
|
||||
typeVersion: 1,
|
||||
position: [300, 100],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.details.errors).toHaveLength(1);
|
||||
expect(result.details.errors[0]).toContain('Node 2'); // Zero-indexed
|
||||
expect(result.details.errors[0]).toContain('Webhook');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGetWorkflow', () => {
|
||||
@@ -542,7 +864,7 @@ describe('handlers-n8n-manager', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'n8n server error. Please try again later or contact support.',
|
||||
error: 'Service unavailable',
|
||||
code: 'SERVER_ERROR',
|
||||
details: {
|
||||
apiUrl: 'https://n8n.test.com',
|
||||
@@ -642,4 +964,179 @@ describe('handlers-n8n-manager', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleTriggerWebhookWorkflow', () => {
|
||||
it('should trigger webhook successfully', async () => {
|
||||
const webhookResponse = {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: { result: 'success' },
|
||||
headers: {}
|
||||
};
|
||||
|
||||
mockApiClient.triggerWebhook.mockResolvedValue(webhookResponse);
|
||||
|
||||
const result = await handlers.handleTriggerWebhookWorkflow({
|
||||
webhookUrl: 'https://n8n.test.com/webhook/test-123',
|
||||
httpMethod: 'POST',
|
||||
data: { test: 'data' }
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
data: webhookResponse,
|
||||
message: 'Webhook triggered successfully'
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract execution ID from webhook error response', async () => {
|
||||
const apiError = new N8nServerError('Workflow execution failed');
|
||||
apiError.details = {
|
||||
executionId: 'exec_abc123',
|
||||
workflowId: 'wf_xyz789'
|
||||
};
|
||||
|
||||
mockApiClient.triggerWebhook.mockRejectedValue(apiError);
|
||||
|
||||
const result = await handlers.handleTriggerWebhookWorkflow({
|
||||
webhookUrl: 'https://n8n.test.com/webhook/test-123',
|
||||
httpMethod: 'POST'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Workflow wf_xyz789 execution exec_abc123 failed');
|
||||
expect(result.error).toContain('n8n_get_execution');
|
||||
expect(result.error).toContain("mode: 'preview'");
|
||||
expect(result.executionId).toBe('exec_abc123');
|
||||
expect(result.workflowId).toBe('wf_xyz789');
|
||||
});
|
||||
|
||||
it('should extract execution ID without workflow ID', async () => {
|
||||
const apiError = new N8nServerError('Execution failed');
|
||||
apiError.details = {
|
||||
executionId: 'exec_only_123'
|
||||
};
|
||||
|
||||
mockApiClient.triggerWebhook.mockRejectedValue(apiError);
|
||||
|
||||
const result = await handlers.handleTriggerWebhookWorkflow({
|
||||
webhookUrl: 'https://n8n.test.com/webhook/test-123',
|
||||
httpMethod: 'GET'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Execution exec_only_123 failed');
|
||||
expect(result.error).toContain('n8n_get_execution');
|
||||
expect(result.error).toContain("mode: 'preview'");
|
||||
expect(result.executionId).toBe('exec_only_123');
|
||||
expect(result.workflowId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle execution ID as "id" field', async () => {
|
||||
const apiError = new N8nServerError('Error');
|
||||
apiError.details = {
|
||||
id: 'exec_from_id_field',
|
||||
workflowId: 'wf_test'
|
||||
};
|
||||
|
||||
mockApiClient.triggerWebhook.mockRejectedValue(apiError);
|
||||
|
||||
const result = await handlers.handleTriggerWebhookWorkflow({
|
||||
webhookUrl: 'https://n8n.test.com/webhook/test',
|
||||
httpMethod: 'POST'
|
||||
});
|
||||
|
||||
expect(result.error).toContain('exec_from_id_field');
|
||||
expect(result.executionId).toBe('exec_from_id_field');
|
||||
});
|
||||
|
||||
it('should provide generic guidance when no execution ID is available', async () => {
|
||||
const apiError = new N8nServerError('Server error without execution context');
|
||||
apiError.details = {}; // No execution ID
|
||||
|
||||
mockApiClient.triggerWebhook.mockRejectedValue(apiError);
|
||||
|
||||
const result = await handlers.handleTriggerWebhookWorkflow({
|
||||
webhookUrl: 'https://n8n.test.com/webhook/test',
|
||||
httpMethod: 'POST'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Workflow failed to execute');
|
||||
expect(result.error).toContain('n8n_list_executions');
|
||||
expect(result.error).toContain('n8n_get_execution');
|
||||
expect(result.error).toContain("mode='preview'");
|
||||
expect(result.executionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use standard error message for authentication errors', async () => {
|
||||
const authError = new N8nAuthenticationError('Invalid API key');
|
||||
mockApiClient.triggerWebhook.mockRejectedValue(authError);
|
||||
|
||||
const result = await handlers.handleTriggerWebhookWorkflow({
|
||||
webhookUrl: 'https://n8n.test.com/webhook/test',
|
||||
httpMethod: 'POST'
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Failed to authenticate with n8n. Please check your API key.',
|
||||
code: 'AUTHENTICATION_ERROR',
|
||||
details: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('should use standard error message for validation errors', async () => {
|
||||
const validationError = new N8nValidationError('Invalid webhook URL');
|
||||
mockApiClient.triggerWebhook.mockRejectedValue(validationError);
|
||||
|
||||
const result = await handlers.handleTriggerWebhookWorkflow({
|
||||
webhookUrl: 'https://n8n.test.com/webhook/test',
|
||||
httpMethod: 'POST'
|
||||
});
|
||||
|
||||
expect(result.error).toBe('Invalid request: Invalid webhook URL');
|
||||
expect(result.code).toBe('VALIDATION_ERROR');
|
||||
});
|
||||
|
||||
it('should handle invalid input with Zod validation error', async () => {
|
||||
const result = await handlers.handleTriggerWebhookWorkflow({
|
||||
webhookUrl: 'not-a-url',
|
||||
httpMethod: 'INVALID_METHOD'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Invalid input');
|
||||
expect(result.details).toHaveProperty('errors');
|
||||
});
|
||||
|
||||
it('should not include "contact support" in error messages', async () => {
|
||||
const apiError = new N8nServerError('Test error');
|
||||
apiError.details = { executionId: 'test_exec' };
|
||||
|
||||
mockApiClient.triggerWebhook.mockRejectedValue(apiError);
|
||||
|
||||
const result = await handlers.handleTriggerWebhookWorkflow({
|
||||
webhookUrl: 'https://n8n.test.com/webhook/test',
|
||||
httpMethod: 'POST'
|
||||
});
|
||||
|
||||
expect(result.error?.toLowerCase()).not.toContain('contact support');
|
||||
expect(result.error?.toLowerCase()).not.toContain('try again later');
|
||||
});
|
||||
|
||||
it('should always recommend preview mode in error messages', async () => {
|
||||
const apiError = new N8nServerError('Error');
|
||||
apiError.details = { executionId: 'test_123' };
|
||||
|
||||
mockApiClient.triggerWebhook.mockRejectedValue(apiError);
|
||||
|
||||
const result = await handlers.handleTriggerWebhookWorkflow({
|
||||
webhookUrl: 'https://n8n.test.com/webhook/test',
|
||||
httpMethod: 'POST'
|
||||
});
|
||||
|
||||
expect(result.error).toMatch(/mode:\s*'preview'/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -499,7 +499,7 @@ describe('handlers-workflow-diff', () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'n8n server error. Please try again later or contact support.',
|
||||
error: 'Internal server error',
|
||||
code: 'SERVER_ERROR',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,7 +145,7 @@ describe('Parameter Validation', () => {
|
||||
vi.spyOn(server as any, 'getNodeDocumentation').mockResolvedValue({ docs: 'test' });
|
||||
vi.spyOn(server as any, 'getNodeEssentials').mockResolvedValue({ essentials: true });
|
||||
vi.spyOn(server as any, 'searchNodeProperties').mockResolvedValue({ properties: [] });
|
||||
vi.spyOn(server as any, 'getNodeForTask').mockResolvedValue({ node: 'test' });
|
||||
// Note: getNodeForTask removed in v2.15.0
|
||||
vi.spyOn(server as any, 'validateNodeConfig').mockResolvedValue({ valid: true });
|
||||
vi.spyOn(server as any, 'validateNodeMinimal').mockResolvedValue({ missing: [] });
|
||||
vi.spyOn(server as any, 'getPropertyDependencies').mockResolvedValue({ dependencies: {} });
|
||||
@@ -477,7 +477,7 @@ describe('Parameter Validation', () => {
|
||||
{ name: 'get_node_documentation', args: {}, expected: 'Missing required parameters for get_node_documentation: nodeType' },
|
||||
{ name: 'get_node_essentials', args: {}, expected: 'Missing required parameters for get_node_essentials: nodeType' },
|
||||
{ name: 'search_node_properties', args: {}, expected: 'Missing required parameters for search_node_properties: nodeType, query' },
|
||||
{ name: 'get_node_for_task', args: {}, expected: 'Missing required parameters for get_node_for_task: task' },
|
||||
// Note: get_node_for_task removed in v2.15.0
|
||||
{ name: 'get_property_dependencies', args: {}, expected: 'Missing required parameters for get_property_dependencies: nodeType' },
|
||||
{ name: 'get_node_as_tool_info', args: {}, expected: 'Missing required parameters for get_node_as_tool_info: nodeType' },
|
||||
{ name: 'get_template', args: {}, expected: 'Missing required parameters for get_template: templateId' },
|
||||
|
||||
383
tests/unit/mcp/search-nodes-examples.test.ts
Normal file
383
tests/unit/mcp/search-nodes-examples.test.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { N8NDocumentationMCPServer } from '../../../src/mcp/server';
|
||||
import { createDatabaseAdapter } from '../../../src/database/database-adapter';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
/**
|
||||
* Unit tests for search_nodes with includeExamples parameter
|
||||
* Testing P0-R3 feature: Template-based configuration examples
|
||||
*/
|
||||
|
||||
describe('search_nodes with includeExamples', () => {
|
||||
let server: N8NDocumentationMCPServer;
|
||||
let dbPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Use in-memory database for testing
|
||||
process.env.NODE_DB_PATH = ':memory:';
|
||||
server = new N8NDocumentationMCPServer();
|
||||
await (server as any).initialized;
|
||||
|
||||
// Populate in-memory database with test nodes
|
||||
// NOTE: Database stores nodes in SHORT form (nodes-base.xxx, not n8n-nodes-base.xxx)
|
||||
const testNodes = [
|
||||
{
|
||||
node_type: 'nodes-base.webhook',
|
||||
package_name: 'n8n-nodes-base',
|
||||
display_name: 'Webhook',
|
||||
description: 'Starts workflow on webhook call',
|
||||
category: 'Core Nodes',
|
||||
is_ai_tool: 0,
|
||||
is_trigger: 1,
|
||||
is_webhook: 1,
|
||||
is_versioned: 1,
|
||||
version: '1',
|
||||
properties_schema: JSON.stringify([]),
|
||||
operations: JSON.stringify([])
|
||||
},
|
||||
{
|
||||
node_type: 'nodes-base.httpRequest',
|
||||
package_name: 'n8n-nodes-base',
|
||||
display_name: 'HTTP Request',
|
||||
description: 'Makes an HTTP request',
|
||||
category: 'Core Nodes',
|
||||
is_ai_tool: 0,
|
||||
is_trigger: 0,
|
||||
is_webhook: 0,
|
||||
is_versioned: 1,
|
||||
version: '1',
|
||||
properties_schema: JSON.stringify([]),
|
||||
operations: JSON.stringify([])
|
||||
}
|
||||
];
|
||||
|
||||
// Insert test nodes into the in-memory database
|
||||
const db = (server as any).db;
|
||||
if (db) {
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO nodes (
|
||||
node_type, package_name, display_name, description, category,
|
||||
is_ai_tool, is_trigger, is_webhook, is_versioned, version,
|
||||
properties_schema, operations
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const node of testNodes) {
|
||||
insertStmt.run(
|
||||
node.node_type,
|
||||
node.package_name,
|
||||
node.display_name,
|
||||
node.description,
|
||||
node.category,
|
||||
node.is_ai_tool,
|
||||
node.is_trigger,
|
||||
node.is_webhook,
|
||||
node.is_versioned,
|
||||
node.version,
|
||||
node.properties_schema,
|
||||
node.operations
|
||||
);
|
||||
}
|
||||
// Note: FTS table is not created in test environment
|
||||
// searchNodes will fall back to LIKE search when FTS doesn't exist
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NODE_DB_PATH;
|
||||
});
|
||||
|
||||
describe('includeExamples parameter', () => {
|
||||
it('should not include examples when includeExamples is false', async () => {
|
||||
const result = await (server as any).searchNodes('webhook', 5, { includeExamples: false });
|
||||
|
||||
expect(result.results).toBeDefined();
|
||||
if (result.results.length > 0) {
|
||||
result.results.forEach((node: any) => {
|
||||
expect(node.examples).toBeUndefined();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should not include examples when includeExamples is undefined', async () => {
|
||||
const result = await (server as any).searchNodes('webhook', 5, {});
|
||||
|
||||
expect(result.results).toBeDefined();
|
||||
if (result.results.length > 0) {
|
||||
result.results.forEach((node: any) => {
|
||||
expect(node.examples).toBeUndefined();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should include examples when includeExamples is true', async () => {
|
||||
const result = await (server as any).searchNodes('webhook', 5, { includeExamples: true });
|
||||
|
||||
expect(result.results).toBeDefined();
|
||||
// Note: In-memory test database may not have template configs
|
||||
// This test validates the parameter is processed correctly
|
||||
});
|
||||
|
||||
it('should handle nodes without examples gracefully', async () => {
|
||||
const result = await (server as any).searchNodes('nonexistent', 5, { includeExamples: true });
|
||||
|
||||
expect(result.results).toBeDefined();
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should limit examples to top 2 per node', async () => {
|
||||
// This test would need a database with actual template_node_configs data
|
||||
// In a real scenario, we'd verify that only 2 examples are returned
|
||||
const result = await (server as any).searchNodes('http', 5, { includeExamples: true });
|
||||
|
||||
expect(result.results).toBeDefined();
|
||||
if (result.results.length > 0) {
|
||||
result.results.forEach((node: any) => {
|
||||
if (node.examples) {
|
||||
expect(node.examples.length).toBeLessThanOrEqual(2);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('example data structure', () => {
|
||||
it('should return examples with correct structure when present', async () => {
|
||||
// Mock database to return example data
|
||||
const mockDb = (server as any).db;
|
||||
if (mockDb) {
|
||||
const originalPrepare = mockDb.prepare.bind(mockDb);
|
||||
mockDb.prepare = vi.fn((query: string) => {
|
||||
if (query.includes('template_node_configs')) {
|
||||
return {
|
||||
all: vi.fn(() => [
|
||||
{
|
||||
parameters_json: JSON.stringify({
|
||||
httpMethod: 'POST',
|
||||
path: 'webhook-test'
|
||||
}),
|
||||
template_name: 'Test Template',
|
||||
template_views: 1000
|
||||
},
|
||||
{
|
||||
parameters_json: JSON.stringify({
|
||||
httpMethod: 'GET',
|
||||
path: 'webhook-get'
|
||||
}),
|
||||
template_name: 'Another Template',
|
||||
template_views: 500
|
||||
}
|
||||
])
|
||||
};
|
||||
}
|
||||
return originalPrepare(query);
|
||||
});
|
||||
|
||||
const result = await (server as any).searchNodes('webhook', 5, { includeExamples: true });
|
||||
|
||||
if (result.results.length > 0 && result.results[0].examples) {
|
||||
const example = result.results[0].examples[0];
|
||||
expect(example).toHaveProperty('configuration');
|
||||
expect(example).toHaveProperty('template');
|
||||
expect(example).toHaveProperty('views');
|
||||
expect(typeof example.configuration).toBe('object');
|
||||
expect(typeof example.template).toBe('string');
|
||||
expect(typeof example.views).toBe('number');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('backward compatibility', () => {
|
||||
it('should maintain backward compatibility when includeExamples not specified', async () => {
|
||||
const resultWithoutParam = await (server as any).searchNodes('http', 5);
|
||||
const resultWithFalse = await (server as any).searchNodes('http', 5, { includeExamples: false });
|
||||
|
||||
expect(resultWithoutParam.results).toBeDefined();
|
||||
expect(resultWithFalse.results).toBeDefined();
|
||||
|
||||
// Both should have same structure (no examples)
|
||||
if (resultWithoutParam.results.length > 0) {
|
||||
expect(resultWithoutParam.results[0].examples).toBeUndefined();
|
||||
}
|
||||
if (resultWithFalse.results.length > 0) {
|
||||
expect(resultWithFalse.results[0].examples).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance considerations', () => {
|
||||
it('should not significantly impact performance when includeExamples is false', async () => {
|
||||
const startWithout = Date.now();
|
||||
await (server as any).searchNodes('http', 20, { includeExamples: false });
|
||||
const durationWithout = Date.now() - startWithout;
|
||||
|
||||
const startWith = Date.now();
|
||||
await (server as any).searchNodes('http', 20, { includeExamples: true });
|
||||
const durationWith = Date.now() - startWith;
|
||||
|
||||
// Both should complete quickly (under 100ms)
|
||||
expect(durationWithout).toBeLessThan(100);
|
||||
expect(durationWith).toBeLessThan(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should continue to work even if example fetch fails', async () => {
|
||||
// Mock database to throw error on example fetch
|
||||
const mockDb = (server as any).db;
|
||||
if (mockDb) {
|
||||
const originalPrepare = mockDb.prepare.bind(mockDb);
|
||||
mockDb.prepare = vi.fn((query: string) => {
|
||||
if (query.includes('template_node_configs')) {
|
||||
throw new Error('Database error');
|
||||
}
|
||||
return originalPrepare(query);
|
||||
});
|
||||
|
||||
// Should not throw, should return results without examples
|
||||
const result = await (server as any).searchNodes('webhook', 5, { includeExamples: true });
|
||||
|
||||
expect(result.results).toBeDefined();
|
||||
// Examples should be undefined due to error
|
||||
if (result.results.length > 0) {
|
||||
expect(result.results[0].examples).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle malformed parameters_json gracefully', async () => {
|
||||
const mockDb = (server as any).db;
|
||||
if (mockDb) {
|
||||
const originalPrepare = mockDb.prepare.bind(mockDb);
|
||||
mockDb.prepare = vi.fn((query: string) => {
|
||||
if (query.includes('template_node_configs')) {
|
||||
return {
|
||||
all: vi.fn(() => [
|
||||
{
|
||||
parameters_json: 'invalid json',
|
||||
template_name: 'Test Template',
|
||||
template_views: 1000
|
||||
}
|
||||
])
|
||||
};
|
||||
}
|
||||
return originalPrepare(query);
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
const result = await (server as any).searchNodes('webhook', 5, { includeExamples: true });
|
||||
expect(result).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchNodesLIKE with includeExamples', () => {
|
||||
let server: N8NDocumentationMCPServer;
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.NODE_DB_PATH = ':memory:';
|
||||
server = new N8NDocumentationMCPServer();
|
||||
await (server as any).initialized;
|
||||
|
||||
// Populate in-memory database with test nodes
|
||||
const testNodes = [
|
||||
{
|
||||
node_type: 'nodes-base.webhook',
|
||||
package_name: 'n8n-nodes-base',
|
||||
display_name: 'Webhook',
|
||||
description: 'Starts workflow on webhook call',
|
||||
category: 'Core Nodes',
|
||||
is_ai_tool: 0,
|
||||
is_trigger: 1,
|
||||
is_webhook: 1,
|
||||
is_versioned: 1,
|
||||
version: '1',
|
||||
properties_schema: JSON.stringify([]),
|
||||
operations: JSON.stringify([])
|
||||
}
|
||||
];
|
||||
|
||||
const db = (server as any).db;
|
||||
if (db) {
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO nodes (
|
||||
node_type, package_name, display_name, description, category,
|
||||
is_ai_tool, is_trigger, is_webhook, is_versioned, version,
|
||||
properties_schema, operations
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const node of testNodes) {
|
||||
insertStmt.run(
|
||||
node.node_type,
|
||||
node.package_name,
|
||||
node.display_name,
|
||||
node.description,
|
||||
node.category,
|
||||
node.is_ai_tool,
|
||||
node.is_trigger,
|
||||
node.is_webhook,
|
||||
node.is_versioned,
|
||||
node.version,
|
||||
node.properties_schema,
|
||||
node.operations
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NODE_DB_PATH;
|
||||
});
|
||||
|
||||
it('should support includeExamples in LIKE search', async () => {
|
||||
const result = await (server as any).searchNodesLIKE('webhook', 5, { includeExamples: true });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not include examples when includeExamples is false', async () => {
|
||||
const result = await (server as any).searchNodesLIKE('webhook', 5, { includeExamples: false });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
if (result.results.length > 0) {
|
||||
result.results.forEach((node: any) => {
|
||||
expect(node.examples).toBeUndefined();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchNodesFTS with includeExamples', () => {
|
||||
let server: N8NDocumentationMCPServer;
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.NODE_DB_PATH = ':memory:';
|
||||
server = new N8NDocumentationMCPServer();
|
||||
await (server as any).initialized;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NODE_DB_PATH;
|
||||
});
|
||||
|
||||
it('should support includeExamples in FTS search', async () => {
|
||||
const result = await (server as any).searchNodesFTS('webhook', 5, 'OR', { includeExamples: true });
|
||||
|
||||
expect(result.results).toBeDefined();
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
});
|
||||
|
||||
it('should pass options to example fetching logic', async () => {
|
||||
const result = await (server as any).searchNodesFTS('http', 5, 'AND', { includeExamples: true });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.results).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -254,7 +254,7 @@ describe('n8nDocumentationToolsFinal', () => {
|
||||
discovery: ['list_nodes', 'search_nodes', 'list_ai_tools'],
|
||||
configuration: ['get_node_info', 'get_node_essentials', 'get_node_documentation'],
|
||||
validation: ['validate_node_operation', 'validate_workflow', 'validate_node_minimal'],
|
||||
templates: ['list_tasks', 'get_node_for_task', 'search_templates', 'list_templates', 'get_template', 'list_node_templates'],
|
||||
templates: ['list_tasks', 'search_templates', 'list_templates', 'get_template', 'list_node_templates'], // get_node_for_task removed in v2.15.0
|
||||
documentation: ['tools_documentation']
|
||||
};
|
||||
|
||||
|
||||
456
tests/unit/scripts/fetch-templates-extraction.test.ts
Normal file
456
tests/unit/scripts/fetch-templates-extraction.test.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import * as zlib from 'zlib';
|
||||
|
||||
/**
|
||||
* Unit tests for template configuration extraction functions
|
||||
* Testing the core logic from fetch-templates.ts
|
||||
*/
|
||||
|
||||
// Extract the functions to test by importing or recreating them
|
||||
function extractNodeConfigs(
|
||||
templateId: number,
|
||||
templateName: string,
|
||||
templateViews: number,
|
||||
workflowCompressed: string,
|
||||
metadata: any
|
||||
): Array<{
|
||||
node_type: string;
|
||||
template_id: number;
|
||||
template_name: string;
|
||||
template_views: number;
|
||||
node_name: string;
|
||||
parameters_json: string;
|
||||
credentials_json: string | null;
|
||||
has_credentials: number;
|
||||
has_expressions: number;
|
||||
complexity: string;
|
||||
use_cases: string;
|
||||
}> {
|
||||
try {
|
||||
const decompressed = zlib.gunzipSync(Buffer.from(workflowCompressed, 'base64'));
|
||||
const workflow = JSON.parse(decompressed.toString('utf-8'));
|
||||
|
||||
const configs: any[] = [];
|
||||
|
||||
for (const node of workflow.nodes || []) {
|
||||
if (node.type.includes('stickyNote') || !node.parameters) {
|
||||
continue;
|
||||
}
|
||||
|
||||
configs.push({
|
||||
node_type: node.type,
|
||||
template_id: templateId,
|
||||
template_name: templateName,
|
||||
template_views: templateViews,
|
||||
node_name: node.name,
|
||||
parameters_json: JSON.stringify(node.parameters),
|
||||
credentials_json: node.credentials ? JSON.stringify(node.credentials) : null,
|
||||
has_credentials: node.credentials ? 1 : 0,
|
||||
has_expressions: detectExpressions(node.parameters) ? 1 : 0,
|
||||
complexity: metadata?.complexity || 'medium',
|
||||
use_cases: JSON.stringify(metadata?.use_cases || [])
|
||||
});
|
||||
}
|
||||
|
||||
return configs;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function detectExpressions(params: any): boolean {
|
||||
if (!params) return false;
|
||||
const json = JSON.stringify(params);
|
||||
return json.includes('={{') || json.includes('$json') || json.includes('$node');
|
||||
}
|
||||
|
||||
describe('Template Configuration Extraction', () => {
|
||||
describe('extractNodeConfigs', () => {
|
||||
it('should extract configs from valid workflow with multiple nodes', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {
|
||||
httpMethod: 'POST',
|
||||
path: 'webhook-test'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'node2',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [300, 100],
|
||||
parameters: {
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
const metadata = {
|
||||
complexity: 'simple',
|
||||
use_cases: ['webhook processing', 'API calls']
|
||||
};
|
||||
|
||||
const configs = extractNodeConfigs(1, 'Test Template', 500, compressed, metadata);
|
||||
|
||||
expect(configs).toHaveLength(2);
|
||||
expect(configs[0].node_type).toBe('n8n-nodes-base.webhook');
|
||||
expect(configs[0].node_name).toBe('Webhook');
|
||||
expect(configs[0].template_id).toBe(1);
|
||||
expect(configs[0].template_name).toBe('Test Template');
|
||||
expect(configs[0].template_views).toBe(500);
|
||||
expect(configs[0].has_credentials).toBe(0);
|
||||
expect(configs[0].complexity).toBe('simple');
|
||||
|
||||
const parsedParams = JSON.parse(configs[0].parameters_json);
|
||||
expect(parsedParams.httpMethod).toBe('POST');
|
||||
expect(parsedParams.path).toBe('webhook-test');
|
||||
|
||||
expect(configs[1].node_type).toBe('n8n-nodes-base.httpRequest');
|
||||
expect(configs[1].node_name).toBe('HTTP Request');
|
||||
});
|
||||
|
||||
it('should return empty array for workflow with no nodes', () => {
|
||||
const workflow = { nodes: [], connections: {} };
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
|
||||
const configs = extractNodeConfigs(1, 'Empty Template', 100, compressed, null);
|
||||
|
||||
expect(configs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should skip sticky note nodes', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'sticky1',
|
||||
name: 'Note',
|
||||
type: 'n8n-nodes-base.stickyNote',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: { content: 'This is a note' }
|
||||
},
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [300, 100],
|
||||
parameters: { url: 'https://api.example.com' }
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
|
||||
|
||||
expect(configs).toHaveLength(1);
|
||||
expect(configs[0].node_type).toBe('n8n-nodes-base.httpRequest');
|
||||
});
|
||||
|
||||
it('should skip nodes without parameters', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'No Params',
|
||||
type: 'n8n-nodes-base.someNode',
|
||||
typeVersion: 1,
|
||||
position: [100, 100]
|
||||
// No parameters field
|
||||
},
|
||||
{
|
||||
id: 'node2',
|
||||
name: 'With Params',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [300, 100],
|
||||
parameters: { url: 'https://api.example.com' }
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
|
||||
|
||||
expect(configs).toHaveLength(1);
|
||||
expect(configs[0].node_type).toBe('n8n-nodes-base.httpRequest');
|
||||
});
|
||||
|
||||
it('should handle nodes with credentials', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'Slack',
|
||||
type: 'n8n-nodes-base.slack',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {
|
||||
resource: 'message',
|
||||
operation: 'post'
|
||||
},
|
||||
credentials: {
|
||||
slackApi: {
|
||||
id: '1',
|
||||
name: 'Slack API'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
|
||||
|
||||
expect(configs).toHaveLength(1);
|
||||
expect(configs[0].has_credentials).toBe(1);
|
||||
expect(configs[0].credentials_json).toBeTruthy();
|
||||
|
||||
const creds = JSON.parse(configs[0].credentials_json!);
|
||||
expect(creds.slackApi).toBeDefined();
|
||||
});
|
||||
|
||||
it('should use default complexity when metadata is missing', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [100, 100],
|
||||
parameters: { url: 'https://api.example.com' }
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
|
||||
|
||||
expect(configs[0].complexity).toBe('medium');
|
||||
expect(configs[0].use_cases).toBe('[]');
|
||||
});
|
||||
|
||||
it('should handle malformed compressed data gracefully', () => {
|
||||
const invalidCompressed = 'invalid-base64-data';
|
||||
const configs = extractNodeConfigs(1, 'Test', 100, invalidCompressed, null);
|
||||
|
||||
expect(configs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle invalid JSON after decompression', () => {
|
||||
const invalidJson = 'not valid json';
|
||||
const compressed = zlib.gzipSync(Buffer.from(invalidJson)).toString('base64');
|
||||
const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
|
||||
|
||||
expect(configs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle workflows with missing nodes array', () => {
|
||||
const workflow = { connections: {} };
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
|
||||
|
||||
expect(configs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectExpressions', () => {
|
||||
it('should detect n8n expression syntax with ={{...}}', () => {
|
||||
const params = {
|
||||
url: '={{ $json.apiUrl }}',
|
||||
method: 'GET'
|
||||
};
|
||||
|
||||
expect(detectExpressions(params)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect $json references', () => {
|
||||
const params = {
|
||||
body: {
|
||||
data: '$json.data'
|
||||
}
|
||||
};
|
||||
|
||||
expect(detectExpressions(params)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect $node references', () => {
|
||||
const params = {
|
||||
url: 'https://api.example.com',
|
||||
headers: {
|
||||
authorization: '$node["Webhook"].json.token'
|
||||
}
|
||||
};
|
||||
|
||||
expect(detectExpressions(params)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for parameters without expressions', () => {
|
||||
const params = {
|
||||
url: 'https://api.example.com',
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'test'
|
||||
}
|
||||
};
|
||||
|
||||
expect(detectExpressions(params)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle nested objects with expressions', () => {
|
||||
const params = {
|
||||
options: {
|
||||
queryParameters: {
|
||||
filters: {
|
||||
id: '={{ $json.userId }}'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
expect(detectExpressions(params)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for null parameters', () => {
|
||||
expect(detectExpressions(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined parameters', () => {
|
||||
expect(detectExpressions(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty object', () => {
|
||||
expect(detectExpressions({})).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle array parameters with expressions', () => {
|
||||
const params = {
|
||||
items: [
|
||||
{ value: '={{ $json.item1 }}' },
|
||||
{ value: '={{ $json.item2 }}' }
|
||||
]
|
||||
};
|
||||
|
||||
expect(detectExpressions(params)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect multiple expression types in same params', () => {
|
||||
const params = {
|
||||
url: '={{ $node["HTTP Request"].json.nextUrl }}',
|
||||
body: {
|
||||
data: '$json.data',
|
||||
token: '={{ $json.token }}'
|
||||
}
|
||||
};
|
||||
|
||||
expect(detectExpressions(params)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle very large workflows without crashing', () => {
|
||||
const nodes = Array.from({ length: 100 }, (_, i) => ({
|
||||
id: `node${i}`,
|
||||
name: `Node ${i}`,
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [100 * i, 100],
|
||||
parameters: {
|
||||
url: `https://api.example.com/${i}`,
|
||||
method: 'GET'
|
||||
}
|
||||
}));
|
||||
|
||||
const workflow = { nodes, connections: {} };
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
const configs = extractNodeConfigs(1, 'Large Template', 1000, compressed, null);
|
||||
|
||||
expect(configs).toHaveLength(100);
|
||||
});
|
||||
|
||||
it('should handle special characters in node names and parameters', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'Node with 特殊文字 & émojis 🎉',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [100, 100],
|
||||
parameters: {
|
||||
url: 'https://api.example.com?query=test&special=值',
|
||||
headers: {
|
||||
'X-Custom-Header': 'value with spaces & symbols!@#$%'
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
|
||||
|
||||
expect(configs).toHaveLength(1);
|
||||
expect(configs[0].node_name).toBe('Node with 特殊文字 & émojis 🎉');
|
||||
|
||||
const params = JSON.parse(configs[0].parameters_json);
|
||||
expect(params.headers['X-Custom-Header']).toBe('value with spaces & symbols!@#$%');
|
||||
});
|
||||
|
||||
it('should preserve parameter structure exactly as in workflow', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'node1',
|
||||
name: 'Complex Node',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 3,
|
||||
position: [100, 100],
|
||||
parameters: {
|
||||
url: 'https://api.example.com',
|
||||
options: {
|
||||
queryParameters: {
|
||||
filters: [
|
||||
{ name: 'status', value: 'active' },
|
||||
{ name: 'type', value: 'user' }
|
||||
]
|
||||
},
|
||||
timeout: 10000,
|
||||
redirect: {
|
||||
followRedirects: true,
|
||||
maxRedirects: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(workflow))).toString('base64');
|
||||
const configs = extractNodeConfigs(1, 'Test', 100, compressed, null);
|
||||
|
||||
const params = JSON.parse(configs[0].parameters_json);
|
||||
expect(params.options.queryParameters.filters).toHaveLength(2);
|
||||
expect(params.options.timeout).toBe(10000);
|
||||
expect(params.options.redirect.maxRedirects).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
665
tests/unit/services/execution-processor.test.ts
Normal file
665
tests/unit/services/execution-processor.test.ts
Normal file
@@ -0,0 +1,665 @@
|
||||
/**
|
||||
* Execution Processor Service Tests
|
||||
*
|
||||
* Comprehensive test coverage for execution filtering and processing
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
generatePreview,
|
||||
filterExecutionData,
|
||||
processExecution,
|
||||
} from '../../../src/services/execution-processor';
|
||||
import {
|
||||
Execution,
|
||||
ExecutionStatus,
|
||||
ExecutionFilterOptions,
|
||||
} from '../../../src/types/n8n-api';
|
||||
|
||||
/**
|
||||
* Test data factories
|
||||
*/
|
||||
|
||||
function createMockExecution(options: {
|
||||
id?: string;
|
||||
status?: ExecutionStatus;
|
||||
nodeData?: Record<string, any>;
|
||||
hasError?: boolean;
|
||||
}): Execution {
|
||||
const { id = 'test-exec-1', status = ExecutionStatus.SUCCESS, nodeData = {}, hasError = false } = options;
|
||||
|
||||
return {
|
||||
id,
|
||||
workflowId: 'workflow-1',
|
||||
status,
|
||||
mode: 'manual',
|
||||
finished: true,
|
||||
startedAt: '2024-01-01T10:00:00.000Z',
|
||||
stoppedAt: '2024-01-01T10:00:05.000Z',
|
||||
data: {
|
||||
resultData: {
|
||||
runData: nodeData,
|
||||
error: hasError ? { message: 'Test error' } : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createNodeData(itemCount: number, includeError = false) {
|
||||
const items = Array.from({ length: itemCount }, (_, i) => ({
|
||||
json: {
|
||||
id: i + 1,
|
||||
name: `Item ${i + 1}`,
|
||||
value: Math.random() * 100,
|
||||
nested: {
|
||||
field1: `value${i}`,
|
||||
field2: true,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
startTime: Date.now(),
|
||||
executionTime: 123,
|
||||
data: {
|
||||
main: [items],
|
||||
},
|
||||
error: includeError ? { message: 'Node error' } : undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview Mode Tests
|
||||
*/
|
||||
describe('ExecutionProcessor - Preview Mode', () => {
|
||||
it('should generate preview for empty execution', () => {
|
||||
const execution = createMockExecution({ nodeData: {} });
|
||||
const { preview, recommendation } = generatePreview(execution);
|
||||
|
||||
expect(preview.totalNodes).toBe(0);
|
||||
expect(preview.executedNodes).toBe(0);
|
||||
expect(preview.estimatedSizeKB).toBe(0);
|
||||
expect(recommendation.canFetchFull).toBe(true);
|
||||
expect(recommendation.suggestedMode).toBe('full'); // Empty execution is safe to fetch in full
|
||||
});
|
||||
|
||||
it('should generate preview with accurate item counts', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(50),
|
||||
'Filter': createNodeData(12),
|
||||
},
|
||||
});
|
||||
|
||||
const { preview } = generatePreview(execution);
|
||||
|
||||
expect(preview.totalNodes).toBe(2);
|
||||
expect(preview.executedNodes).toBe(2);
|
||||
expect(preview.nodes['HTTP Request'].itemCounts.output).toBe(50);
|
||||
expect(preview.nodes['Filter'].itemCounts.output).toBe(12);
|
||||
});
|
||||
|
||||
it('should extract data structure from nodes', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(5),
|
||||
},
|
||||
});
|
||||
|
||||
const { preview } = generatePreview(execution);
|
||||
const structure = preview.nodes['HTTP Request'].dataStructure;
|
||||
|
||||
expect(structure).toHaveProperty('json');
|
||||
expect(structure.json).toHaveProperty('id');
|
||||
expect(structure.json).toHaveProperty('name');
|
||||
expect(structure.json).toHaveProperty('nested');
|
||||
expect(structure.json.id).toBe('number');
|
||||
expect(structure.json.name).toBe('string');
|
||||
expect(typeof structure.json.nested).toBe('object');
|
||||
});
|
||||
|
||||
it('should estimate data size', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(50),
|
||||
},
|
||||
});
|
||||
|
||||
const { preview } = generatePreview(execution);
|
||||
|
||||
expect(preview.estimatedSizeKB).toBeGreaterThan(0);
|
||||
expect(preview.nodes['HTTP Request'].estimatedSizeKB).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should detect error status in nodes', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(5, true),
|
||||
},
|
||||
});
|
||||
|
||||
const { preview } = generatePreview(execution);
|
||||
|
||||
expect(preview.nodes['HTTP Request'].status).toBe('error');
|
||||
expect(preview.nodes['HTTP Request'].error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should recommend full mode for small datasets', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(5),
|
||||
},
|
||||
});
|
||||
|
||||
const { recommendation } = generatePreview(execution);
|
||||
|
||||
expect(recommendation.canFetchFull).toBe(true);
|
||||
expect(recommendation.suggestedMode).toBe('full');
|
||||
});
|
||||
|
||||
it('should recommend filtered mode for large datasets', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(100),
|
||||
},
|
||||
});
|
||||
|
||||
const { recommendation } = generatePreview(execution);
|
||||
|
||||
expect(recommendation.canFetchFull).toBe(false);
|
||||
expect(recommendation.suggestedMode).toBe('filtered');
|
||||
expect(recommendation.suggestedItemsLimit).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should recommend summary mode for moderate datasets', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(30),
|
||||
},
|
||||
});
|
||||
|
||||
const { recommendation } = generatePreview(execution);
|
||||
|
||||
expect(recommendation.canFetchFull).toBe(false);
|
||||
expect(recommendation.suggestedMode).toBe('summary');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Filtering Mode Tests
|
||||
*/
|
||||
describe('ExecutionProcessor - Filtering', () => {
|
||||
it('should filter by node names', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(10),
|
||||
'Filter': createNodeData(5),
|
||||
'Set': createNodeData(3),
|
||||
},
|
||||
});
|
||||
|
||||
const options: ExecutionFilterOptions = {
|
||||
mode: 'filtered',
|
||||
nodeNames: ['HTTP Request', 'Filter'],
|
||||
};
|
||||
|
||||
const result = filterExecutionData(execution, options);
|
||||
|
||||
expect(result.nodes).toHaveProperty('HTTP Request');
|
||||
expect(result.nodes).toHaveProperty('Filter');
|
||||
expect(result.nodes).not.toHaveProperty('Set');
|
||||
expect(result.summary?.executedNodes).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle non-existent node names gracefully', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(10),
|
||||
},
|
||||
});
|
||||
|
||||
const options: ExecutionFilterOptions = {
|
||||
mode: 'filtered',
|
||||
nodeNames: ['NonExistent'],
|
||||
};
|
||||
|
||||
const result = filterExecutionData(execution, options);
|
||||
|
||||
expect(Object.keys(result.nodes || {})).toHaveLength(0);
|
||||
expect(result.summary?.executedNodes).toBe(0);
|
||||
});
|
||||
|
||||
it('should limit items to 0 (structure only)', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(50),
|
||||
},
|
||||
});
|
||||
|
||||
const options: ExecutionFilterOptions = {
|
||||
mode: 'filtered',
|
||||
itemsLimit: 0,
|
||||
};
|
||||
|
||||
const result = filterExecutionData(execution, options);
|
||||
const nodeData = result.nodes?.['HTTP Request'];
|
||||
|
||||
expect(nodeData?.data?.metadata.itemsShown).toBe(0);
|
||||
expect(nodeData?.data?.metadata.truncated).toBe(true);
|
||||
expect(nodeData?.data?.metadata.totalItems).toBe(50);
|
||||
|
||||
// Check that we have structure but no actual values
|
||||
const output = nodeData?.data?.output?.[0]?.[0];
|
||||
expect(output).toBeDefined();
|
||||
expect(typeof output).toBe('object');
|
||||
});
|
||||
|
||||
it('should limit items to 2 (default)', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(50),
|
||||
},
|
||||
});
|
||||
|
||||
const options: ExecutionFilterOptions = {
|
||||
mode: 'summary',
|
||||
};
|
||||
|
||||
const result = filterExecutionData(execution, options);
|
||||
const nodeData = result.nodes?.['HTTP Request'];
|
||||
|
||||
expect(nodeData?.data?.metadata.itemsShown).toBe(2);
|
||||
expect(nodeData?.data?.metadata.totalItems).toBe(50);
|
||||
expect(nodeData?.data?.metadata.truncated).toBe(true);
|
||||
expect(nodeData?.data?.output?.[0]).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should limit items to custom value', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(50),
|
||||
},
|
||||
});
|
||||
|
||||
const options: ExecutionFilterOptions = {
|
||||
mode: 'filtered',
|
||||
itemsLimit: 5,
|
||||
};
|
||||
|
||||
const result = filterExecutionData(execution, options);
|
||||
const nodeData = result.nodes?.['HTTP Request'];
|
||||
|
||||
expect(nodeData?.data?.metadata.itemsShown).toBe(5);
|
||||
expect(nodeData?.data?.metadata.truncated).toBe(true);
|
||||
expect(nodeData?.data?.output?.[0]).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should not truncate when itemsLimit is -1 (unlimited)', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(50),
|
||||
},
|
||||
});
|
||||
|
||||
const options: ExecutionFilterOptions = {
|
||||
mode: 'filtered',
|
||||
itemsLimit: -1,
|
||||
};
|
||||
|
||||
const result = filterExecutionData(execution, options);
|
||||
const nodeData = result.nodes?.['HTTP Request'];
|
||||
|
||||
expect(nodeData?.data?.metadata.itemsShown).toBe(50);
|
||||
expect(nodeData?.data?.metadata.totalItems).toBe(50);
|
||||
expect(nodeData?.data?.metadata.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('should not truncate when items are less than limit', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(3),
|
||||
},
|
||||
});
|
||||
|
||||
const options: ExecutionFilterOptions = {
|
||||
mode: 'filtered',
|
||||
itemsLimit: 5,
|
||||
};
|
||||
|
||||
const result = filterExecutionData(execution, options);
|
||||
const nodeData = result.nodes?.['HTTP Request'];
|
||||
|
||||
expect(nodeData?.data?.metadata.itemsShown).toBe(3);
|
||||
expect(nodeData?.data?.metadata.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('should include input data when requested', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': [
|
||||
{
|
||||
startTime: Date.now(),
|
||||
executionTime: 100,
|
||||
inputData: [[{ json: { input: 'test' } }]],
|
||||
data: {
|
||||
main: [[{ json: { output: 'result' } }]],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const options: ExecutionFilterOptions = {
|
||||
mode: 'filtered',
|
||||
includeInputData: true,
|
||||
};
|
||||
|
||||
const result = filterExecutionData(execution, options);
|
||||
const nodeData = result.nodes?.['HTTP Request'];
|
||||
|
||||
expect(nodeData?.data?.input).toBeDefined();
|
||||
expect(nodeData?.data?.input?.[0]?.[0]?.json?.input).toBe('test');
|
||||
});
|
||||
|
||||
it('should not include input data by default', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': [
|
||||
{
|
||||
startTime: Date.now(),
|
||||
executionTime: 100,
|
||||
inputData: [[{ json: { input: 'test' } }]],
|
||||
data: {
|
||||
main: [[{ json: { output: 'result' } }]],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const options: ExecutionFilterOptions = {
|
||||
mode: 'filtered',
|
||||
};
|
||||
|
||||
const result = filterExecutionData(execution, options);
|
||||
const nodeData = result.nodes?.['HTTP Request'];
|
||||
|
||||
expect(nodeData?.data?.input).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Mode Tests
|
||||
*/
|
||||
describe('ExecutionProcessor - Modes', () => {
|
||||
it('should handle preview mode', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(50),
|
||||
},
|
||||
});
|
||||
|
||||
const result = filterExecutionData(execution, { mode: 'preview' });
|
||||
|
||||
expect(result.mode).toBe('preview');
|
||||
expect(result.preview).toBeDefined();
|
||||
expect(result.recommendation).toBeDefined();
|
||||
expect(result.nodes).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle summary mode', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(50),
|
||||
},
|
||||
});
|
||||
|
||||
const result = filterExecutionData(execution, { mode: 'summary' });
|
||||
|
||||
expect(result.mode).toBe('summary');
|
||||
expect(result.summary).toBeDefined();
|
||||
expect(result.nodes).toBeDefined();
|
||||
expect(result.nodes?.['HTTP Request']?.data?.metadata.itemsShown).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle filtered mode', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(50),
|
||||
},
|
||||
});
|
||||
|
||||
const result = filterExecutionData(execution, {
|
||||
mode: 'filtered',
|
||||
itemsLimit: 5,
|
||||
});
|
||||
|
||||
expect(result.mode).toBe('filtered');
|
||||
expect(result.summary).toBeDefined();
|
||||
expect(result.nodes?.['HTTP Request']?.data?.metadata.itemsShown).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle full mode', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(50),
|
||||
},
|
||||
});
|
||||
|
||||
const result = filterExecutionData(execution, { mode: 'full' });
|
||||
|
||||
expect(result.mode).toBe('full');
|
||||
expect(result.nodes?.['HTTP Request']?.data?.metadata.itemsShown).toBe(50);
|
||||
expect(result.nodes?.['HTTP Request']?.data?.metadata.truncated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Edge Cases
|
||||
*/
|
||||
describe('ExecutionProcessor - Edge Cases', () => {
|
||||
it('should handle execution with no data', () => {
|
||||
const execution: Execution = {
|
||||
id: 'test-1',
|
||||
workflowId: 'workflow-1',
|
||||
status: ExecutionStatus.SUCCESS,
|
||||
mode: 'manual',
|
||||
finished: true,
|
||||
startedAt: '2024-01-01T10:00:00.000Z',
|
||||
stoppedAt: '2024-01-01T10:00:05.000Z',
|
||||
};
|
||||
|
||||
const result = filterExecutionData(execution, { mode: 'summary' });
|
||||
|
||||
expect(result.summary?.totalNodes).toBe(0);
|
||||
expect(result.summary?.executedNodes).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle execution with error', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(5),
|
||||
},
|
||||
hasError: true,
|
||||
});
|
||||
|
||||
const result = filterExecutionData(execution, { mode: 'summary' });
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle empty node data arrays', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': [],
|
||||
},
|
||||
});
|
||||
|
||||
const result = filterExecutionData(execution, { mode: 'summary' });
|
||||
|
||||
expect(result.nodes?.['HTTP Request']).toBeDefined();
|
||||
expect(result.nodes?.['HTTP Request'].itemsOutput).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle nested data structures', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': [
|
||||
{
|
||||
startTime: Date.now(),
|
||||
executionTime: 100,
|
||||
data: {
|
||||
main: [[{
|
||||
json: {
|
||||
deeply: {
|
||||
nested: {
|
||||
structure: {
|
||||
value: 'test',
|
||||
array: [1, 2, 3],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}]],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { preview } = generatePreview(execution);
|
||||
const structure = preview.nodes['HTTP Request'].dataStructure;
|
||||
|
||||
expect(structure.json.deeply).toBeDefined();
|
||||
expect(typeof structure.json.deeply).toBe('object');
|
||||
});
|
||||
|
||||
it('should calculate duration correctly', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(5),
|
||||
},
|
||||
});
|
||||
|
||||
const result = filterExecutionData(execution, { mode: 'summary' });
|
||||
|
||||
expect(result.duration).toBe(5000); // 5 seconds
|
||||
});
|
||||
|
||||
it('should handle execution without stop time', () => {
|
||||
const execution: Execution = {
|
||||
id: 'test-1',
|
||||
workflowId: 'workflow-1',
|
||||
status: ExecutionStatus.WAITING,
|
||||
mode: 'manual',
|
||||
finished: false,
|
||||
startedAt: '2024-01-01T10:00:00.000Z',
|
||||
data: {
|
||||
resultData: {
|
||||
runData: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = filterExecutionData(execution, { mode: 'summary' });
|
||||
|
||||
expect(result.duration).toBeUndefined();
|
||||
expect(result.finished).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* processExecution Tests
|
||||
*/
|
||||
describe('ExecutionProcessor - processExecution', () => {
|
||||
it('should return original execution when no options provided', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(5),
|
||||
},
|
||||
});
|
||||
|
||||
const result = processExecution(execution, {});
|
||||
|
||||
expect(result).toBe(execution);
|
||||
});
|
||||
|
||||
it('should process when mode is specified', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(5),
|
||||
},
|
||||
});
|
||||
|
||||
const result = processExecution(execution, { mode: 'preview' });
|
||||
|
||||
expect(result).not.toBe(execution);
|
||||
expect((result as any).mode).toBe('preview');
|
||||
});
|
||||
|
||||
it('should process when filtering options are provided', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(5),
|
||||
'Filter': createNodeData(3),
|
||||
},
|
||||
});
|
||||
|
||||
const result = processExecution(execution, { nodeNames: ['HTTP Request'] });
|
||||
|
||||
expect(result).not.toBe(execution);
|
||||
expect((result as any).nodes).toHaveProperty('HTTP Request');
|
||||
expect((result as any).nodes).not.toHaveProperty('Filter');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Summary Statistics Tests
|
||||
*/
|
||||
describe('ExecutionProcessor - Summary Statistics', () => {
|
||||
it('should calculate hasMoreData correctly', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(50),
|
||||
},
|
||||
});
|
||||
|
||||
const result = filterExecutionData(execution, {
|
||||
mode: 'summary',
|
||||
itemsLimit: 2,
|
||||
});
|
||||
|
||||
expect(result.summary?.hasMoreData).toBe(true);
|
||||
});
|
||||
|
||||
it('should set hasMoreData to false when all data is included', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(2),
|
||||
},
|
||||
});
|
||||
|
||||
const result = filterExecutionData(execution, {
|
||||
mode: 'summary',
|
||||
itemsLimit: 5,
|
||||
});
|
||||
|
||||
expect(result.summary?.hasMoreData).toBe(false);
|
||||
});
|
||||
|
||||
it('should count total items correctly across multiple nodes', () => {
|
||||
const execution = createMockExecution({
|
||||
nodeData: {
|
||||
'HTTP Request': createNodeData(10),
|
||||
'Filter': createNodeData(5),
|
||||
'Set': createNodeData(3),
|
||||
},
|
||||
});
|
||||
|
||||
const result = filterExecutionData(execution, { mode: 'summary' });
|
||||
|
||||
expect(result.summary?.totalItems).toBe(18);
|
||||
});
|
||||
});
|
||||
@@ -344,12 +344,12 @@ describe('n8n-validation', () => {
|
||||
expect(cleaned).not.toHaveProperty('shared');
|
||||
expect(cleaned).not.toHaveProperty('active');
|
||||
|
||||
// Should keep these fields
|
||||
// Should keep name but replace settings with empty object (n8n API limitation)
|
||||
expect(cleaned.name).toBe('Updated Workflow');
|
||||
expect(cleaned.settings).toEqual({ executionOrder: 'v1' });
|
||||
expect(cleaned.settings).toEqual({});
|
||||
});
|
||||
|
||||
it('should not add default settings for update', () => {
|
||||
it('should add empty settings object for cloud API compatibility', () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
@@ -357,7 +357,80 @@ describe('n8n-validation', () => {
|
||||
} as any;
|
||||
|
||||
const cleaned = cleanWorkflowForUpdate(workflow);
|
||||
expect(cleaned).not.toHaveProperty('settings');
|
||||
expect(cleaned.settings).toEqual({});
|
||||
});
|
||||
|
||||
it('should replace settings with empty object to prevent API errors (Issue #248 - final fix)', () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
settings: {
|
||||
executionOrder: 'v1' as const,
|
||||
saveDataSuccessExecution: 'none' as const,
|
||||
callerPolicy: 'workflowsFromSameOwner' as const,
|
||||
timeSavedPerExecution: 5, // UI-only property
|
||||
},
|
||||
} as any;
|
||||
|
||||
const cleaned = cleanWorkflowForUpdate(workflow);
|
||||
|
||||
// Settings replaced with empty object (satisfies both API versions)
|
||||
expect(cleaned.settings).toEqual({});
|
||||
});
|
||||
|
||||
it('should replace settings with callerPolicy (Issue #248 - API limitation)', () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
settings: {
|
||||
executionOrder: 'v1' as const,
|
||||
callerPolicy: 'workflowsFromSameOwner' as const,
|
||||
errorWorkflow: 'N2O2nZy3aUiBRGFN',
|
||||
},
|
||||
} as any;
|
||||
|
||||
const cleaned = cleanWorkflowForUpdate(workflow);
|
||||
|
||||
// Settings replaced with empty object (n8n API rejects updates with settings properties)
|
||||
expect(cleaned.settings).toEqual({});
|
||||
});
|
||||
|
||||
it('should replace all settings regardless of content (Issue #248 - API design)', () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
settings: {
|
||||
executionOrder: 'v0' as const,
|
||||
timezone: 'UTC',
|
||||
saveDataErrorExecution: 'all' as const,
|
||||
saveDataSuccessExecution: 'none' as const,
|
||||
saveManualExecutions: false,
|
||||
saveExecutionProgress: false,
|
||||
executionTimeout: 300,
|
||||
errorWorkflow: 'error-workflow-id',
|
||||
callerPolicy: 'workflowsFromAList' as const,
|
||||
},
|
||||
} as any;
|
||||
|
||||
const cleaned = cleanWorkflowForUpdate(workflow);
|
||||
|
||||
// Settings replaced with empty object due to n8n API limitation (cannot update settings via API)
|
||||
// See: https://community.n8n.io/t/api-workflow-update-endpoint-doesnt-support-setting-callerpolicy/161916
|
||||
expect(cleaned.settings).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle workflows without settings gracefully', () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
} as any;
|
||||
|
||||
const cleaned = cleanWorkflowForUpdate(workflow);
|
||||
expect(cleaned.settings).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1236,7 +1309,7 @@ describe('n8n-validation', () => {
|
||||
expect(forUpdate).not.toHaveProperty('active');
|
||||
expect(forUpdate).not.toHaveProperty('tags');
|
||||
expect(forUpdate).not.toHaveProperty('meta');
|
||||
expect(forUpdate).not.toHaveProperty('settings'); // Should not add defaults for update
|
||||
expect(forUpdate.settings).toEqual({}); // Settings replaced with empty object for API compatibility
|
||||
expect(validateWorkflowStructure(forUpdate)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -579,7 +579,6 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
expect(mockNodeRepository.getNode).toHaveBeenCalledWith('n8n-nodes-base.webhook');
|
||||
expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-base.webhook');
|
||||
});
|
||||
|
||||
@@ -599,7 +598,6 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
expect(mockNodeRepository.getNode).toHaveBeenCalledWith('@n8n/n8n-nodes-langchain.agent');
|
||||
expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-langchain.agent');
|
||||
});
|
||||
|
||||
|
||||
@@ -645,9 +645,9 @@ describe('WorkflowValidator - Mock-based Unit Tests', () => {
|
||||
|
||||
await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Should have called getNode for each node type
|
||||
expect(mockGetNode).toHaveBeenCalledWith('n8n-nodes-base.httpRequest');
|
||||
expect(mockGetNode).toHaveBeenCalledWith('n8n-nodes-base.set');
|
||||
// Should have called getNode for each node type (normalized to short form)
|
||||
expect(mockGetNode).toHaveBeenCalledWith('nodes-base.httpRequest');
|
||||
expect(mockGetNode).toHaveBeenCalledWith('nodes-base.set');
|
||||
expect(mockGetNode).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -712,7 +712,7 @@ describe('WorkflowValidator - Mock-based Unit Tests', () => {
|
||||
// Should call getNode for the same type multiple times (current implementation)
|
||||
// Note: This test documents current behavior. Could be optimized in the future.
|
||||
const httpRequestCalls = mockGetNode.mock.calls.filter(
|
||||
call => call[0] === 'n8n-nodes-base.httpRequest'
|
||||
call => call[0] === 'nodes-base.httpRequest'
|
||||
);
|
||||
expect(httpRequestCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -449,10 +449,10 @@ describe('WorkflowValidator - Simple Unit Tests', () => {
|
||||
});
|
||||
|
||||
it('should normalize and validate nodes-base prefix to find the node', async () => {
|
||||
// Arrange - Test that nodes-base prefix is normalized to find the node
|
||||
// The repository only has the node under the normalized key
|
||||
// Arrange - Test that full-form types are normalized to short form to find the node
|
||||
// The repository only has the node under the SHORT normalized key (database format)
|
||||
const nodeData = {
|
||||
'nodes-base.webhook': { // Repository has it under normalized form
|
||||
'nodes-base.webhook': { // Repository has it under SHORT form (database format)
|
||||
type: 'nodes-base.webhook',
|
||||
displayName: 'Webhook',
|
||||
isVersioned: true,
|
||||
@@ -462,10 +462,11 @@ describe('WorkflowValidator - Simple Unit Tests', () => {
|
||||
};
|
||||
|
||||
// Mock repository that simulates the normalization behavior
|
||||
// After our changes, getNode is called with the already-normalized type (short form)
|
||||
const mockRepository = {
|
||||
getNode: vi.fn((type: string) => {
|
||||
// First call with original type returns null
|
||||
// Second call with normalized type returns the node
|
||||
// The validator now normalizes to short form before calling getNode
|
||||
// So getNode receives 'nodes-base.webhook'
|
||||
if (type === 'nodes-base.webhook') {
|
||||
return nodeData['nodes-base.webhook'];
|
||||
}
|
||||
@@ -489,7 +490,7 @@ describe('WorkflowValidator - Simple Unit Tests', () => {
|
||||
{
|
||||
id: '1',
|
||||
name: 'Webhook',
|
||||
type: 'nodes-base.webhook', // Using the alternative prefix
|
||||
type: 'n8n-nodes-base.webhook', // Using the full-form prefix (will be normalized to short)
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {},
|
||||
typeVersion: 2
|
||||
|
||||
@@ -192,7 +192,7 @@ describe('TelemetryEventTracker', () => {
|
||||
|
||||
describe('trackError()', () => {
|
||||
it('should track error events without rate limiting', () => {
|
||||
eventTracker.trackError('ValidationError', 'Node configuration invalid', 'httpRequest');
|
||||
eventTracker.trackError('ValidationError', 'Node configuration invalid', 'httpRequest', 'Required field "url" is missing');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events).toHaveLength(1);
|
||||
@@ -202,34 +202,173 @@ describe('TelemetryEventTracker', () => {
|
||||
properties: {
|
||||
errorType: 'ValidationError',
|
||||
context: 'Node configuration invalid',
|
||||
tool: 'httpRequest'
|
||||
tool: 'httpRequest',
|
||||
error: 'Required field "url" is missing'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should sanitize error context', () => {
|
||||
const context = 'Failed to connect to https://api.example.com with key abc123def456ghi789jklmno0123456789';
|
||||
eventTracker.trackError('NetworkError', context);
|
||||
eventTracker.trackError('NetworkError', context, undefined, 'Connection timeout after 30s');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.context).toBe('Failed to connect to [URL] with key [KEY]');
|
||||
});
|
||||
|
||||
it('should sanitize error type', () => {
|
||||
eventTracker.trackError('Invalid$Error!Type', 'test context');
|
||||
eventTracker.trackError('Invalid$Error!Type', 'test context', undefined, 'Test error message');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.errorType).toBe('Invalid_Error_Type');
|
||||
});
|
||||
|
||||
it('should handle missing tool name', () => {
|
||||
eventTracker.trackError('TestError', 'test context');
|
||||
eventTracker.trackError('TestError', 'test context', undefined, 'No tool specified');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.tool).toBeNull(); // Validator converts undefined to null
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackError() with error messages', () => {
|
||||
it('should capture error messages in properties', () => {
|
||||
eventTracker.trackError('ValidationError', 'test', 'tool', 'Field "url" is required');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toBe('Field "url" is required');
|
||||
});
|
||||
|
||||
it('should handle undefined error message', () => {
|
||||
eventTracker.trackError('Error', 'test', 'tool', undefined);
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toBeNull(); // Validator converts undefined to null
|
||||
});
|
||||
|
||||
it('should sanitize API keys in error messages', () => {
|
||||
eventTracker.trackError('AuthError', 'test', 'tool', 'Failed with api_key=sk_live_abc123def456');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('api_key=[REDACTED]');
|
||||
expect(events[0].properties.error).not.toContain('sk_live_abc123def456');
|
||||
});
|
||||
|
||||
it('should sanitize passwords in error messages', () => {
|
||||
eventTracker.trackError('AuthError', 'test', 'tool', 'Login failed: password=secret123');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('password=[REDACTED]');
|
||||
});
|
||||
|
||||
it('should sanitize long keys (32+ chars)', () => {
|
||||
eventTracker.trackError('Error', 'test', 'tool', 'Key: abc123def456ghi789jkl012mno345pqr678');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('[KEY]');
|
||||
});
|
||||
|
||||
it('should sanitize URLs in error messages', () => {
|
||||
eventTracker.trackError('NetworkError', 'test', 'tool', 'Failed to fetch https://api.example.com/v1/users');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toBe('Failed to fetch [URL]');
|
||||
expect(events[0].properties.error).not.toContain('api.example.com');
|
||||
expect(events[0].properties.error).not.toContain('/v1/users');
|
||||
});
|
||||
|
||||
it('should truncate very long error messages to 500 chars', () => {
|
||||
const longError = 'Error occurred while processing the request. ' + 'Additional context details. '.repeat(50);
|
||||
eventTracker.trackError('Error', 'test', 'tool', longError);
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error.length).toBeLessThanOrEqual(503); // 500 + '...'
|
||||
expect(events[0].properties.error).toMatch(/\.\.\.$/);
|
||||
});
|
||||
|
||||
it('should handle stack traces by keeping first 3 lines', () => {
|
||||
const errorMsg = 'Error: Something failed\n at foo (/path/file.js:10:5)\n at bar (/path/file.js:20:10)\n at baz (/path/file.js:30:15)\n at qux (/path/file.js:40:20)';
|
||||
eventTracker.trackError('Error', 'test', 'tool', errorMsg);
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
const lines = events[0].properties.error.split('\n');
|
||||
expect(lines.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should sanitize emails in error messages', () => {
|
||||
eventTracker.trackError('Error', 'test', 'tool', 'Failed for user test@example.com');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('[EMAIL]');
|
||||
expect(events[0].properties.error).not.toContain('test@example.com');
|
||||
});
|
||||
|
||||
it('should sanitize quoted tokens', () => {
|
||||
eventTracker.trackError('Error', 'test', 'tool', 'Auth failed: "abc123def456ghi789"');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('"[TOKEN]"');
|
||||
});
|
||||
|
||||
it('should sanitize token= patterns in error messages', () => {
|
||||
eventTracker.trackError('AuthError', 'test', 'tool', 'Failed with token=abc123def456');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('token=[REDACTED]');
|
||||
});
|
||||
|
||||
it('should sanitize AWS access keys', () => {
|
||||
eventTracker.trackError('Error', 'test', 'tool', 'Failed with AWS key AKIAIOSFODNN7EXAMPLE');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('[AWS_KEY]');
|
||||
expect(events[0].properties.error).not.toContain('AKIAIOSFODNN7EXAMPLE');
|
||||
});
|
||||
|
||||
it('should sanitize GitHub tokens', () => {
|
||||
eventTracker.trackError('Error', 'test', 'tool', 'Auth failed: ghp_1234567890abcdefghijklmnopqrstuvwxyz');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('[GITHUB_TOKEN]');
|
||||
expect(events[0].properties.error).not.toContain('ghp_1234567890abcdefghijklmnopqrstuvwxyz');
|
||||
});
|
||||
|
||||
it('should sanitize JWT tokens', () => {
|
||||
eventTracker.trackError('Error', 'test', 'tool', 'Invalid JWT eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.signature provided');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('[JWT]');
|
||||
expect(events[0].properties.error).not.toContain('eyJhbGciOiJIUzI1NiJ9');
|
||||
});
|
||||
|
||||
it('should sanitize Bearer tokens', () => {
|
||||
eventTracker.trackError('Error', 'test', 'tool', 'Authorization failed: Bearer abc123def456ghi789');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
expect(events[0].properties.error).toContain('Bearer [TOKEN]');
|
||||
expect(events[0].properties.error).not.toContain('abc123def456ghi789');
|
||||
});
|
||||
|
||||
it('should prevent email leakage in URLs by sanitizing URLs first', () => {
|
||||
eventTracker.trackError('Error', 'test', 'tool', 'Failed: https://api.example.com/users/test@example.com/profile');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
// URL should be fully redacted, preventing any email leakage
|
||||
expect(events[0].properties.error).toBe('Failed: [URL]');
|
||||
expect(events[0].properties.error).not.toContain('test@example.com');
|
||||
expect(events[0].properties.error).not.toContain('/users/');
|
||||
});
|
||||
|
||||
it('should handle extremely long error messages efficiently', () => {
|
||||
const hugeError = 'Error: ' + 'x'.repeat(10000);
|
||||
eventTracker.trackError('Error', 'test', 'tool', hugeError);
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
// Should be truncated at 500 chars max
|
||||
expect(events[0].properties.error.length).toBeLessThanOrEqual(503); // 500 + '...'
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackEvent()', () => {
|
||||
it('should track generic events', () => {
|
||||
const properties = { key: 'value', count: 42 };
|
||||
@@ -618,7 +757,7 @@ describe('TelemetryEventTracker', () => {
|
||||
describe('sanitization helpers', () => {
|
||||
it('should sanitize context strings properly', () => {
|
||||
const context = 'Error at https://api.example.com/v1/users/test@email.com?key=secret123456789012345678901234567890';
|
||||
eventTracker.trackError('TestError', context);
|
||||
eventTracker.trackError('TestError', context, undefined, 'Test error with special chars');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
// After sanitization: emails first, then keys, then URL (keeping path)
|
||||
@@ -628,7 +767,7 @@ describe('TelemetryEventTracker', () => {
|
||||
it('should handle context truncation', () => {
|
||||
// Use a more realistic long context that won't trigger key sanitization
|
||||
const longContext = 'Error occurred while processing the request: ' + 'details '.repeat(20);
|
||||
eventTracker.trackError('TestError', longContext);
|
||||
eventTracker.trackError('TestError', longContext, undefined, 'Long error message for truncation test');
|
||||
|
||||
const events = eventTracker.getEventQueue();
|
||||
// Should be truncated to 100 chars
|
||||
|
||||
@@ -233,12 +233,13 @@ describe('TelemetryManager', () => {
|
||||
});
|
||||
|
||||
it('should track errors', () => {
|
||||
manager.trackError('ValidationError', 'Node configuration invalid', 'httpRequest');
|
||||
manager.trackError('ValidationError', 'Node configuration invalid', 'httpRequest', 'Required field "url" is missing');
|
||||
|
||||
expect(mockEventTracker.trackError).toHaveBeenCalledWith(
|
||||
'ValidationError',
|
||||
'Node configuration invalid',
|
||||
'httpRequest'
|
||||
'httpRequest',
|
||||
'Required field "url" is missing'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
793
tests/unit/templates/template-repository-metadata.test.ts
Normal file
793
tests/unit/templates/template-repository-metadata.test.ts
Normal file
@@ -0,0 +1,793 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { TemplateRepository } from '../../../src/templates/template-repository';
|
||||
import { DatabaseAdapter, PreparedStatement, RunResult } from '../../../src/database/database-adapter';
|
||||
import { logger } from '../../../src/utils/logger';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('../../../src/utils/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock template sanitizer
|
||||
vi.mock('../../../src/utils/template-sanitizer', () => {
|
||||
class MockTemplateSanitizer {
|
||||
sanitizeWorkflow = vi.fn((workflow) => ({ sanitized: workflow, wasModified: false }));
|
||||
detectTokens = vi.fn(() => []);
|
||||
}
|
||||
|
||||
return {
|
||||
TemplateSanitizer: MockTemplateSanitizer
|
||||
};
|
||||
});
|
||||
|
||||
// Create mock database adapter
|
||||
class MockDatabaseAdapter implements DatabaseAdapter {
|
||||
private statements = new Map<string, MockPreparedStatement>();
|
||||
private execCalls: string[] = [];
|
||||
private _fts5Support = true;
|
||||
|
||||
prepare = vi.fn((sql: string) => {
|
||||
if (!this.statements.has(sql)) {
|
||||
this.statements.set(sql, new MockPreparedStatement(sql));
|
||||
}
|
||||
return this.statements.get(sql)!;
|
||||
});
|
||||
|
||||
exec = vi.fn((sql: string) => {
|
||||
this.execCalls.push(sql);
|
||||
});
|
||||
close = vi.fn();
|
||||
pragma = vi.fn();
|
||||
transaction = vi.fn((fn: () => any) => fn());
|
||||
checkFTS5Support = vi.fn(() => this._fts5Support);
|
||||
inTransaction = false;
|
||||
|
||||
_setFTS5Support(supported: boolean) {
|
||||
this._fts5Support = supported;
|
||||
}
|
||||
|
||||
_getStatement(sql: string) {
|
||||
return this.statements.get(sql);
|
||||
}
|
||||
|
||||
_getExecCalls() {
|
||||
return this.execCalls;
|
||||
}
|
||||
|
||||
_clearExecCalls() {
|
||||
this.execCalls = [];
|
||||
}
|
||||
}
|
||||
|
||||
class MockPreparedStatement implements PreparedStatement {
|
||||
public mockResults: any[] = [];
|
||||
public capturedParams: any[][] = [];
|
||||
|
||||
run = vi.fn((...params: any[]): RunResult => {
|
||||
this.capturedParams.push(params);
|
||||
return { changes: 1, lastInsertRowid: 1 };
|
||||
});
|
||||
|
||||
get = vi.fn((...params: any[]) => {
|
||||
this.capturedParams.push(params);
|
||||
return this.mockResults[0] || null;
|
||||
});
|
||||
|
||||
all = vi.fn((...params: any[]) => {
|
||||
this.capturedParams.push(params);
|
||||
return this.mockResults;
|
||||
});
|
||||
|
||||
iterate = vi.fn();
|
||||
pluck = vi.fn(() => this);
|
||||
expand = vi.fn(() => this);
|
||||
raw = vi.fn(() => this);
|
||||
columns = vi.fn(() => []);
|
||||
bind = vi.fn(() => this);
|
||||
|
||||
constructor(private sql: string) {}
|
||||
|
||||
_setMockResults(results: any[]) {
|
||||
this.mockResults = results;
|
||||
}
|
||||
|
||||
_getCapturedParams() {
|
||||
return this.capturedParams;
|
||||
}
|
||||
}
|
||||
|
||||
describe('TemplateRepository - Metadata Filter Tests', () => {
|
||||
let repository: TemplateRepository;
|
||||
let mockAdapter: MockDatabaseAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAdapter = new MockDatabaseAdapter();
|
||||
repository = new TemplateRepository(mockAdapter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('buildMetadataFilterConditions - All Filter Combinations', () => {
|
||||
it('should build conditions with no filters', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({}, 10, 0);
|
||||
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
// Should only have the base condition
|
||||
expect(prepareCall).toContain('metadata_json IS NOT NULL');
|
||||
// Should not have any additional conditions
|
||||
expect(prepareCall).not.toContain("json_extract(metadata_json, '$.categories')");
|
||||
expect(prepareCall).not.toContain("json_extract(metadata_json, '$.complexity')");
|
||||
});
|
||||
|
||||
it('should build conditions with only category filter', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({ category: 'automation' }, 10, 0);
|
||||
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain('metadata_json IS NOT NULL');
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0][0]).toBe('automation');
|
||||
});
|
||||
|
||||
it('should build conditions with only complexity filter', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({ complexity: 'simple' }, 10, 0);
|
||||
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain('metadata_json IS NOT NULL');
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.complexity') = ?");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0][0]).toBe('simple');
|
||||
});
|
||||
|
||||
it('should build conditions with only maxSetupMinutes filter', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({ maxSetupMinutes: 30 }, 10, 0);
|
||||
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain('metadata_json IS NOT NULL');
|
||||
expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0][0]).toBe(30);
|
||||
});
|
||||
|
||||
it('should build conditions with only minSetupMinutes filter', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({ minSetupMinutes: 10 }, 10, 0);
|
||||
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain('metadata_json IS NOT NULL');
|
||||
expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0][0]).toBe(10);
|
||||
});
|
||||
|
||||
it('should build conditions with only requiredService filter', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({ requiredService: 'slack' }, 10, 0);
|
||||
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain('metadata_json IS NOT NULL');
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0][0]).toBe('slack');
|
||||
});
|
||||
|
||||
it('should build conditions with only targetAudience filter', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({ targetAudience: 'developers' }, 10, 0);
|
||||
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain('metadata_json IS NOT NULL');
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0][0]).toBe('developers');
|
||||
});
|
||||
|
||||
it('should build conditions with all filters combined', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({
|
||||
category: 'automation',
|
||||
complexity: 'medium',
|
||||
maxSetupMinutes: 60,
|
||||
minSetupMinutes: 15,
|
||||
requiredService: 'openai',
|
||||
targetAudience: 'marketers'
|
||||
}, 10, 0);
|
||||
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain('metadata_json IS NOT NULL');
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.complexity') = ?");
|
||||
expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?");
|
||||
expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?");
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0]).toEqual(['automation', 'medium', 60, 15, 'openai', 'marketers', 10, 0]);
|
||||
});
|
||||
|
||||
it('should build conditions with partial filter combinations', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({
|
||||
category: 'data-processing',
|
||||
maxSetupMinutes: 45,
|
||||
targetAudience: 'analysts'
|
||||
}, 10, 0);
|
||||
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain('metadata_json IS NOT NULL');
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
|
||||
expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?");
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
|
||||
// Should not have complexity, minSetupMinutes, or requiredService conditions
|
||||
expect(prepareCall).not.toContain("json_extract(metadata_json, '$.complexity') = ?");
|
||||
expect(prepareCall).not.toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?");
|
||||
expect(prepareCall).not.toContain("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0]).toEqual(['data-processing', 45, 'analysts', 10, 0]);
|
||||
});
|
||||
|
||||
it('should handle complexity variations', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
// Test each complexity level
|
||||
const complexityLevels: Array<'simple' | 'medium' | 'complex'> = ['simple', 'medium', 'complex'];
|
||||
|
||||
complexityLevels.forEach((complexity) => {
|
||||
vi.clearAllMocks();
|
||||
stmt.capturedParams = [];
|
||||
|
||||
repository.searchTemplatesByMetadata({ complexity }, 10, 0);
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0][0]).toBe(complexity);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle setup minutes edge cases', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
// Test zero values
|
||||
repository.searchTemplatesByMetadata({ maxSetupMinutes: 0, minSetupMinutes: 0 }, 10, 0);
|
||||
|
||||
let capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0]).toContain(0);
|
||||
|
||||
// Test very large values
|
||||
vi.clearAllMocks();
|
||||
stmt.capturedParams = [];
|
||||
repository.searchTemplatesByMetadata({ maxSetupMinutes: 999999 }, 10, 0);
|
||||
|
||||
capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0]).toContain(999999);
|
||||
|
||||
// Test negative values (should still work, though might not make sense semantically)
|
||||
vi.clearAllMocks();
|
||||
stmt.capturedParams = [];
|
||||
repository.searchTemplatesByMetadata({ minSetupMinutes: -10 }, 10, 0);
|
||||
|
||||
capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0]).toContain(-10);
|
||||
});
|
||||
|
||||
it('should sanitize special characters in string filters', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const specialCategory = 'test"with\'quotes';
|
||||
const specialService = 'service\\with\\backslashes';
|
||||
const specialAudience = 'audience\nwith\nnewlines';
|
||||
|
||||
repository.searchTemplatesByMetadata({
|
||||
category: specialCategory,
|
||||
requiredService: specialService,
|
||||
targetAudience: specialAudience
|
||||
}, 10, 0);
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
// JSON.stringify escapes special characters, then slice(1, -1) removes quotes
|
||||
expect(capturedParams[0][0]).toBe(JSON.stringify(specialCategory).slice(1, -1));
|
||||
expect(capturedParams[0][1]).toBe(JSON.stringify(specialService).slice(1, -1));
|
||||
expect(capturedParams[0][2]).toBe(JSON.stringify(specialAudience).slice(1, -1));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance Logging and Timing', () => {
|
||||
it('should log debug info on successful search', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([
|
||||
{ id: 1 },
|
||||
{ id: 2 }
|
||||
]);
|
||||
|
||||
const stmt2 = new MockPreparedStatement('');
|
||||
stmt2._setMockResults([
|
||||
{ id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' },
|
||||
{ id: 2, workflow_id: 2, name: 'Template 2', workflow_json: '{}' }
|
||||
]);
|
||||
|
||||
let callCount = 0;
|
||||
mockAdapter.prepare = vi.fn((sql: string) => {
|
||||
callCount++;
|
||||
return callCount === 1 ? stmt : stmt2;
|
||||
});
|
||||
|
||||
repository.searchTemplatesByMetadata({ complexity: 'simple' }, 10, 0);
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Metadata search found'),
|
||||
expect.objectContaining({
|
||||
filters: { complexity: 'simple' },
|
||||
count: 2,
|
||||
phase1Ms: expect.any(Number),
|
||||
phase2Ms: expect.any(Number),
|
||||
totalMs: expect.any(Number),
|
||||
optimization: 'two-phase-with-ordering'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should log debug info on empty results', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
repository.searchTemplatesByMetadata({ category: 'nonexistent' }, 10, 0);
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'Metadata search found 0 results',
|
||||
expect.objectContaining({
|
||||
filters: { category: 'nonexistent' },
|
||||
phase1Ms: expect.any(Number)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should include all filter types in logs', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const filters = {
|
||||
category: 'automation',
|
||||
complexity: 'medium' as const,
|
||||
maxSetupMinutes: 60,
|
||||
minSetupMinutes: 15,
|
||||
requiredService: 'slack',
|
||||
targetAudience: 'developers'
|
||||
};
|
||||
|
||||
repository.searchTemplatesByMetadata(filters, 10, 0);
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
filters: filters
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ID Filtering and Validation', () => {
|
||||
it('should filter out negative IDs', () => {
|
||||
const stmt1 = new MockPreparedStatement('');
|
||||
stmt1._setMockResults([
|
||||
{ id: 1 },
|
||||
{ id: -5 },
|
||||
{ id: 2 }
|
||||
]);
|
||||
|
||||
const stmt2 = new MockPreparedStatement('');
|
||||
stmt2._setMockResults([
|
||||
{ id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' },
|
||||
{ id: 2, workflow_id: 2, name: 'Template 2', workflow_json: '{}' }
|
||||
]);
|
||||
|
||||
let callCount = 0;
|
||||
mockAdapter.prepare = vi.fn((sql: string) => {
|
||||
callCount++;
|
||||
return callCount === 1 ? stmt1 : stmt2;
|
||||
});
|
||||
|
||||
repository.searchTemplatesByMetadata({}, 10, 0);
|
||||
|
||||
// Should only fetch valid IDs (1 and 2)
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[1][0];
|
||||
expect(prepareCall).toContain('(1, 0)');
|
||||
expect(prepareCall).toContain('(2, 1)');
|
||||
expect(prepareCall).not.toContain('-5');
|
||||
});
|
||||
|
||||
it('should filter out zero IDs', () => {
|
||||
const stmt1 = new MockPreparedStatement('');
|
||||
stmt1._setMockResults([
|
||||
{ id: 0 },
|
||||
{ id: 1 }
|
||||
]);
|
||||
|
||||
const stmt2 = new MockPreparedStatement('');
|
||||
stmt2._setMockResults([
|
||||
{ id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' }
|
||||
]);
|
||||
|
||||
let callCount = 0;
|
||||
mockAdapter.prepare = vi.fn((sql: string) => {
|
||||
callCount++;
|
||||
return callCount === 1 ? stmt1 : stmt2;
|
||||
});
|
||||
|
||||
repository.searchTemplatesByMetadata({}, 10, 0);
|
||||
|
||||
// Should only fetch valid ID (1)
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[1][0];
|
||||
expect(prepareCall).toContain('(1, 0)');
|
||||
expect(prepareCall).not.toContain('(0,');
|
||||
});
|
||||
|
||||
it('should filter out non-integer IDs', () => {
|
||||
const stmt1 = new MockPreparedStatement('');
|
||||
stmt1._setMockResults([
|
||||
{ id: 1 },
|
||||
{ id: 2.5 },
|
||||
{ id: 3 }
|
||||
]);
|
||||
|
||||
const stmt2 = new MockPreparedStatement('');
|
||||
stmt2._setMockResults([
|
||||
{ id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' },
|
||||
{ id: 3, workflow_id: 3, name: 'Template 3', workflow_json: '{}' }
|
||||
]);
|
||||
|
||||
let callCount = 0;
|
||||
mockAdapter.prepare = vi.fn((sql: string) => {
|
||||
callCount++;
|
||||
return callCount === 1 ? stmt1 : stmt2;
|
||||
});
|
||||
|
||||
repository.searchTemplatesByMetadata({}, 10, 0);
|
||||
|
||||
// Should only fetch integer IDs (1 and 3)
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[1][0];
|
||||
expect(prepareCall).toContain('(1, 0)');
|
||||
expect(prepareCall).toContain('(3, 1)');
|
||||
expect(prepareCall).not.toContain('2.5');
|
||||
});
|
||||
|
||||
it('should filter out null IDs', () => {
|
||||
const stmt1 = new MockPreparedStatement('');
|
||||
stmt1._setMockResults([
|
||||
{ id: 1 },
|
||||
{ id: null },
|
||||
{ id: 2 }
|
||||
]);
|
||||
|
||||
const stmt2 = new MockPreparedStatement('');
|
||||
stmt2._setMockResults([
|
||||
{ id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' },
|
||||
{ id: 2, workflow_id: 2, name: 'Template 2', workflow_json: '{}' }
|
||||
]);
|
||||
|
||||
let callCount = 0;
|
||||
mockAdapter.prepare = vi.fn((sql: string) => {
|
||||
callCount++;
|
||||
return callCount === 1 ? stmt1 : stmt2;
|
||||
});
|
||||
|
||||
repository.searchTemplatesByMetadata({}, 10, 0);
|
||||
|
||||
// Should only fetch valid IDs (1 and 2)
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[1][0];
|
||||
expect(prepareCall).toContain('(1, 0)');
|
||||
expect(prepareCall).toContain('(2, 1)');
|
||||
expect(prepareCall).not.toContain('null');
|
||||
});
|
||||
|
||||
it('should warn when no valid IDs after filtering', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([
|
||||
{ id: -1 },
|
||||
{ id: 0 },
|
||||
{ id: null }
|
||||
]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const result = repository.searchTemplatesByMetadata({}, 10, 0);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'No valid IDs after filtering',
|
||||
expect.objectContaining({
|
||||
filters: {},
|
||||
originalCount: 3
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn when some IDs are filtered out', () => {
|
||||
const stmt1 = new MockPreparedStatement('');
|
||||
stmt1._setMockResults([
|
||||
{ id: 1 },
|
||||
{ id: -2 },
|
||||
{ id: 3 },
|
||||
{ id: null }
|
||||
]);
|
||||
|
||||
const stmt2 = new MockPreparedStatement('');
|
||||
stmt2._setMockResults([
|
||||
{ id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' },
|
||||
{ id: 3, workflow_id: 3, name: 'Template 3', workflow_json: '{}' }
|
||||
]);
|
||||
|
||||
let callCount = 0;
|
||||
mockAdapter.prepare = vi.fn((sql: string) => {
|
||||
callCount++;
|
||||
return callCount === 1 ? stmt1 : stmt2;
|
||||
});
|
||||
|
||||
repository.searchTemplatesByMetadata({}, 10, 0);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Some IDs were filtered out as invalid',
|
||||
expect.objectContaining({
|
||||
original: 4,
|
||||
valid: 2,
|
||||
filtered: 2
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not warn when all IDs are valid', () => {
|
||||
const stmt1 = new MockPreparedStatement('');
|
||||
stmt1._setMockResults([
|
||||
{ id: 1 },
|
||||
{ id: 2 },
|
||||
{ id: 3 }
|
||||
]);
|
||||
|
||||
const stmt2 = new MockPreparedStatement('');
|
||||
stmt2._setMockResults([
|
||||
{ id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' },
|
||||
{ id: 2, workflow_id: 2, name: 'Template 2', workflow_json: '{}' },
|
||||
{ id: 3, workflow_id: 3, name: 'Template 3', workflow_json: '{}' }
|
||||
]);
|
||||
|
||||
let callCount = 0;
|
||||
mockAdapter.prepare = vi.fn((sql: string) => {
|
||||
callCount++;
|
||||
return callCount === 1 ? stmt1 : stmt2;
|
||||
});
|
||||
|
||||
repository.searchTemplatesByMetadata({}, 10, 0);
|
||||
|
||||
expect(logger.warn).not.toHaveBeenCalledWith(
|
||||
'Some IDs were filtered out as invalid',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMetadataSearchCount - Shared Helper Usage', () => {
|
||||
it('should use buildMetadataFilterConditions for category', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([{ count: 5 }]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const result = repository.getMetadataSearchCount({ category: 'automation' });
|
||||
|
||||
expect(result).toBe(5);
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0][0]).toBe('automation');
|
||||
});
|
||||
|
||||
it('should use buildMetadataFilterConditions for complexity', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([{ count: 10 }]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const result = repository.getMetadataSearchCount({ complexity: 'medium' });
|
||||
|
||||
expect(result).toBe(10);
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.complexity') = ?");
|
||||
});
|
||||
|
||||
it('should use buildMetadataFilterConditions for setup minutes', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([{ count: 3 }]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const result = repository.getMetadataSearchCount({
|
||||
maxSetupMinutes: 30,
|
||||
minSetupMinutes: 10
|
||||
});
|
||||
|
||||
expect(result).toBe(3);
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?");
|
||||
expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?");
|
||||
});
|
||||
|
||||
it('should use buildMetadataFilterConditions for service and audience', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([{ count: 7 }]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const result = repository.getMetadataSearchCount({
|
||||
requiredService: 'openai',
|
||||
targetAudience: 'developers'
|
||||
});
|
||||
|
||||
expect(result).toBe(7);
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
|
||||
});
|
||||
|
||||
it('should use buildMetadataFilterConditions with all filters', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([{ count: 2 }]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const result = repository.getMetadataSearchCount({
|
||||
category: 'integration',
|
||||
complexity: 'complex',
|
||||
maxSetupMinutes: 120,
|
||||
minSetupMinutes: 30,
|
||||
requiredService: 'slack',
|
||||
targetAudience: 'marketers'
|
||||
});
|
||||
|
||||
expect(result).toBe(2);
|
||||
const prepareCall = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.categories') LIKE '%' || ? || '%'");
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.complexity') = ?");
|
||||
expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) <= ?");
|
||||
expect(prepareCall).toContain("CAST(json_extract(metadata_json, '$.estimated_setup_minutes') AS INTEGER) >= ?");
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.required_services') LIKE '%' || ? || '%'");
|
||||
expect(prepareCall).toContain("json_extract(metadata_json, '$.target_audience') LIKE '%' || ? || '%'");
|
||||
|
||||
const capturedParams = stmt._getCapturedParams();
|
||||
expect(capturedParams[0]).toEqual(['integration', 'complex', 120, 30, 'slack', 'marketers']);
|
||||
});
|
||||
|
||||
it('should return 0 when no matches', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([{ count: 0 }]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const result = repository.getMetadataSearchCount({ category: 'nonexistent' });
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Two-Phase Query Optimization', () => {
|
||||
it('should execute two separate queries', () => {
|
||||
const stmt1 = new MockPreparedStatement('');
|
||||
stmt1._setMockResults([{ id: 1 }, { id: 2 }]);
|
||||
|
||||
const stmt2 = new MockPreparedStatement('');
|
||||
stmt2._setMockResults([
|
||||
{ id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' },
|
||||
{ id: 2, workflow_id: 2, name: 'Template 2', workflow_json: '{}' }
|
||||
]);
|
||||
|
||||
let callCount = 0;
|
||||
mockAdapter.prepare = vi.fn((sql: string) => {
|
||||
callCount++;
|
||||
return callCount === 1 ? stmt1 : stmt2;
|
||||
});
|
||||
|
||||
repository.searchTemplatesByMetadata({ complexity: 'simple' }, 10, 0);
|
||||
|
||||
expect(mockAdapter.prepare).toHaveBeenCalledTimes(2);
|
||||
|
||||
// First query should select only ID
|
||||
const phase1Query = mockAdapter.prepare.mock.calls[0][0];
|
||||
expect(phase1Query).toContain('SELECT id FROM templates');
|
||||
expect(phase1Query).toContain('ORDER BY views DESC, created_at DESC, id ASC');
|
||||
|
||||
// Second query should use CTE with ordered IDs
|
||||
const phase2Query = mockAdapter.prepare.mock.calls[1][0];
|
||||
expect(phase2Query).toContain('WITH ordered_ids(id, sort_order) AS');
|
||||
expect(phase2Query).toContain('VALUES (1, 0), (2, 1)');
|
||||
expect(phase2Query).toContain('SELECT t.* FROM templates t');
|
||||
expect(phase2Query).toContain('INNER JOIN ordered_ids o ON t.id = o.id');
|
||||
expect(phase2Query).toContain('ORDER BY o.sort_order');
|
||||
});
|
||||
|
||||
it('should skip phase 2 when no IDs found', () => {
|
||||
const stmt = new MockPreparedStatement('');
|
||||
stmt._setMockResults([]);
|
||||
mockAdapter.prepare = vi.fn().mockReturnValue(stmt);
|
||||
|
||||
const result = repository.searchTemplatesByMetadata({ category: 'nonexistent' }, 10, 0);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
// Should only call prepare once (phase 1)
|
||||
expect(mockAdapter.prepare).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should preserve ordering with stable sort', () => {
|
||||
const stmt1 = new MockPreparedStatement('');
|
||||
stmt1._setMockResults([
|
||||
{ id: 5 },
|
||||
{ id: 3 },
|
||||
{ id: 1 }
|
||||
]);
|
||||
|
||||
const stmt2 = new MockPreparedStatement('');
|
||||
stmt2._setMockResults([
|
||||
{ id: 5, workflow_id: 5, name: 'Template 5', workflow_json: '{}' },
|
||||
{ id: 3, workflow_id: 3, name: 'Template 3', workflow_json: '{}' },
|
||||
{ id: 1, workflow_id: 1, name: 'Template 1', workflow_json: '{}' }
|
||||
]);
|
||||
|
||||
let callCount = 0;
|
||||
mockAdapter.prepare = vi.fn((sql: string) => {
|
||||
callCount++;
|
||||
return callCount === 1 ? stmt1 : stmt2;
|
||||
});
|
||||
|
||||
repository.searchTemplatesByMetadata({}, 10, 0);
|
||||
|
||||
// Check that phase 2 query maintains order: (5,0), (3,1), (1,2)
|
||||
const phase2Query = mockAdapter.prepare.mock.calls[1][0];
|
||||
expect(phase2Query).toContain('VALUES (5, 0), (3, 1), (1, 2)');
|
||||
});
|
||||
});
|
||||
});
|
||||
171
tests/unit/utils/n8n-errors.test.ts
Normal file
171
tests/unit/utils/n8n-errors.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatExecutionError,
|
||||
formatNoExecutionError,
|
||||
getUserFriendlyErrorMessage,
|
||||
N8nApiError,
|
||||
N8nAuthenticationError,
|
||||
N8nNotFoundError,
|
||||
N8nValidationError,
|
||||
N8nRateLimitError,
|
||||
N8nServerError
|
||||
} from '../../../src/utils/n8n-errors';
|
||||
|
||||
describe('formatExecutionError', () => {
|
||||
it('should format error with both execution ID and workflow ID', () => {
|
||||
const result = formatExecutionError('exec_12345', 'wf_abc');
|
||||
|
||||
expect(result).toBe("Workflow wf_abc execution exec_12345 failed. Use n8n_get_execution({id: 'exec_12345', mode: 'preview'}) to investigate the error.");
|
||||
expect(result).toContain('mode: \'preview\'');
|
||||
expect(result).toContain('exec_12345');
|
||||
expect(result).toContain('wf_abc');
|
||||
});
|
||||
|
||||
it('should format error with only execution ID', () => {
|
||||
const result = formatExecutionError('exec_67890');
|
||||
|
||||
expect(result).toBe("Execution exec_67890 failed. Use n8n_get_execution({id: 'exec_67890', mode: 'preview'}) to investigate the error.");
|
||||
expect(result).toContain('mode: \'preview\'');
|
||||
expect(result).toContain('exec_67890');
|
||||
expect(result).not.toContain('Workflow');
|
||||
});
|
||||
|
||||
it('should include preview mode guidance', () => {
|
||||
const result = formatExecutionError('test_id');
|
||||
|
||||
expect(result).toMatch(/mode:\s*'preview'/);
|
||||
});
|
||||
|
||||
it('should format with undefined workflow ID (treated as missing)', () => {
|
||||
const result = formatExecutionError('exec_123', undefined);
|
||||
|
||||
expect(result).toBe("Execution exec_123 failed. Use n8n_get_execution({id: 'exec_123', mode: 'preview'}) to investigate the error.");
|
||||
});
|
||||
|
||||
it('should properly escape execution ID in suggestion', () => {
|
||||
const result = formatExecutionError('exec-with-special_chars.123');
|
||||
|
||||
expect(result).toContain("id: 'exec-with-special_chars.123'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatNoExecutionError', () => {
|
||||
it('should provide guidance to check recent executions', () => {
|
||||
const result = formatNoExecutionError();
|
||||
|
||||
expect(result).toBe("Workflow failed to execute. Use n8n_list_executions to find recent executions, then n8n_get_execution with mode='preview' to investigate.");
|
||||
expect(result).toContain('n8n_list_executions');
|
||||
expect(result).toContain('n8n_get_execution');
|
||||
expect(result).toContain("mode='preview'");
|
||||
});
|
||||
|
||||
it('should include preview mode in guidance', () => {
|
||||
const result = formatNoExecutionError();
|
||||
|
||||
expect(result).toMatch(/mode\s*=\s*'preview'/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserFriendlyErrorMessage', () => {
|
||||
it('should handle authentication error', () => {
|
||||
const error = new N8nAuthenticationError('Invalid API key');
|
||||
const message = getUserFriendlyErrorMessage(error);
|
||||
|
||||
expect(message).toBe('Failed to authenticate with n8n. Please check your API key.');
|
||||
});
|
||||
|
||||
it('should handle not found error', () => {
|
||||
const error = new N8nNotFoundError('Workflow', '123');
|
||||
const message = getUserFriendlyErrorMessage(error);
|
||||
|
||||
expect(message).toContain('not found');
|
||||
});
|
||||
|
||||
it('should handle validation error', () => {
|
||||
const error = new N8nValidationError('Missing required field');
|
||||
const message = getUserFriendlyErrorMessage(error);
|
||||
|
||||
expect(message).toBe('Invalid request: Missing required field');
|
||||
});
|
||||
|
||||
it('should handle rate limit error', () => {
|
||||
const error = new N8nRateLimitError(60);
|
||||
const message = getUserFriendlyErrorMessage(error);
|
||||
|
||||
expect(message).toBe('Too many requests. Please wait a moment and try again.');
|
||||
});
|
||||
|
||||
it('should handle server error with custom message', () => {
|
||||
const error = new N8nServerError('Database connection failed', 503);
|
||||
const message = getUserFriendlyErrorMessage(error);
|
||||
|
||||
expect(message).toBe('Database connection failed');
|
||||
});
|
||||
|
||||
it('should handle server error without message', () => {
|
||||
const error = new N8nApiError('', 500, 'SERVER_ERROR');
|
||||
const message = getUserFriendlyErrorMessage(error);
|
||||
|
||||
expect(message).toBe('n8n server error occurred');
|
||||
});
|
||||
|
||||
it('should handle no response error', () => {
|
||||
const error = new N8nApiError('Network error', undefined, 'NO_RESPONSE');
|
||||
const message = getUserFriendlyErrorMessage(error);
|
||||
|
||||
expect(message).toBe('Unable to connect to n8n. Please check the server URL and ensure n8n is running.');
|
||||
});
|
||||
|
||||
it('should handle unknown error with message', () => {
|
||||
const error = new N8nApiError('Custom error message');
|
||||
const message = getUserFriendlyErrorMessage(error);
|
||||
|
||||
expect(message).toBe('Custom error message');
|
||||
});
|
||||
|
||||
it('should handle unknown error without message', () => {
|
||||
const error = new N8nApiError('');
|
||||
const message = getUserFriendlyErrorMessage(error);
|
||||
|
||||
expect(message).toBe('An unexpected error occurred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error message integration', () => {
|
||||
it('should use formatExecutionError for webhook failures with execution ID', () => {
|
||||
const executionId = 'exec_webhook_123';
|
||||
const workflowId = 'wf_webhook_abc';
|
||||
const message = formatExecutionError(executionId, workflowId);
|
||||
|
||||
expect(message).toContain('Workflow wf_webhook_abc execution exec_webhook_123 failed');
|
||||
expect(message).toContain('n8n_get_execution');
|
||||
expect(message).toContain("mode: 'preview'");
|
||||
});
|
||||
|
||||
it('should use formatNoExecutionError for server errors without execution context', () => {
|
||||
const message = formatNoExecutionError();
|
||||
|
||||
expect(message).toContain('Workflow failed to execute');
|
||||
expect(message).toContain('n8n_list_executions');
|
||||
expect(message).toContain('n8n_get_execution');
|
||||
});
|
||||
|
||||
it('should not include "contact support" in any error message', () => {
|
||||
const executionMessage = formatExecutionError('test');
|
||||
const noExecutionMessage = formatNoExecutionError();
|
||||
const serverError = new N8nServerError();
|
||||
const serverErrorMessage = getUserFriendlyErrorMessage(serverError);
|
||||
|
||||
expect(executionMessage.toLowerCase()).not.toContain('contact support');
|
||||
expect(noExecutionMessage.toLowerCase()).not.toContain('contact support');
|
||||
expect(serverErrorMessage.toLowerCase()).not.toContain('contact support');
|
||||
});
|
||||
|
||||
it('should always guide users to use preview mode first', () => {
|
||||
const executionMessage = formatExecutionError('test');
|
||||
const noExecutionMessage = formatNoExecutionError();
|
||||
|
||||
expect(executionMessage).toContain("mode: 'preview'");
|
||||
expect(noExecutionMessage).toContain("mode='preview'");
|
||||
});
|
||||
});
|
||||
340
tests/unit/utils/node-type-normalizer.test.ts
Normal file
340
tests/unit/utils/node-type-normalizer.test.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Tests for NodeTypeNormalizer
|
||||
*
|
||||
* Comprehensive test suite for the node type normalization utility
|
||||
* that fixes the critical issue of AI agents producing short-form node types
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { NodeTypeNormalizer } from '../../../src/utils/node-type-normalizer';
|
||||
|
||||
describe('NodeTypeNormalizer', () => {
|
||||
describe('normalizeToFullForm', () => {
|
||||
describe('Base nodes', () => {
|
||||
it('should normalize full base form to short form', () => {
|
||||
expect(NodeTypeNormalizer.normalizeToFullForm('n8n-nodes-base.webhook'))
|
||||
.toBe('nodes-base.webhook');
|
||||
});
|
||||
|
||||
it('should normalize full base form with different node names', () => {
|
||||
expect(NodeTypeNormalizer.normalizeToFullForm('n8n-nodes-base.httpRequest'))
|
||||
.toBe('nodes-base.httpRequest');
|
||||
expect(NodeTypeNormalizer.normalizeToFullForm('n8n-nodes-base.set'))
|
||||
.toBe('nodes-base.set');
|
||||
expect(NodeTypeNormalizer.normalizeToFullForm('n8n-nodes-base.slack'))
|
||||
.toBe('nodes-base.slack');
|
||||
});
|
||||
|
||||
it('should leave short base form unchanged', () => {
|
||||
expect(NodeTypeNormalizer.normalizeToFullForm('nodes-base.webhook'))
|
||||
.toBe('nodes-base.webhook');
|
||||
expect(NodeTypeNormalizer.normalizeToFullForm('nodes-base.httpRequest'))
|
||||
.toBe('nodes-base.httpRequest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('LangChain nodes', () => {
|
||||
it('should normalize full langchain form to short form', () => {
|
||||
expect(NodeTypeNormalizer.normalizeToFullForm('@n8n/n8n-nodes-langchain.agent'))
|
||||
.toBe('nodes-langchain.agent');
|
||||
expect(NodeTypeNormalizer.normalizeToFullForm('@n8n/n8n-nodes-langchain.openAi'))
|
||||
.toBe('nodes-langchain.openAi');
|
||||
});
|
||||
|
||||
it('should normalize langchain form with n8n- prefix but missing @n8n/', () => {
|
||||
expect(NodeTypeNormalizer.normalizeToFullForm('n8n-nodes-langchain.agent'))
|
||||
.toBe('nodes-langchain.agent');
|
||||
});
|
||||
|
||||
it('should leave short langchain form unchanged', () => {
|
||||
expect(NodeTypeNormalizer.normalizeToFullForm('nodes-langchain.agent'))
|
||||
.toBe('nodes-langchain.agent');
|
||||
expect(NodeTypeNormalizer.normalizeToFullForm('nodes-langchain.openAi'))
|
||||
.toBe('nodes-langchain.openAi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle empty string', () => {
|
||||
expect(NodeTypeNormalizer.normalizeToFullForm('')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle null', () => {
|
||||
expect(NodeTypeNormalizer.normalizeToFullForm(null as any)).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle undefined', () => {
|
||||
expect(NodeTypeNormalizer.normalizeToFullForm(undefined as any)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should handle non-string input', () => {
|
||||
expect(NodeTypeNormalizer.normalizeToFullForm(123 as any)).toBe(123);
|
||||
expect(NodeTypeNormalizer.normalizeToFullForm({} as any)).toEqual({});
|
||||
});
|
||||
|
||||
it('should leave community nodes unchanged', () => {
|
||||
expect(NodeTypeNormalizer.normalizeToFullForm('custom-package.myNode'))
|
||||
.toBe('custom-package.myNode');
|
||||
});
|
||||
|
||||
it('should leave nodes without prefixes unchanged', () => {
|
||||
expect(NodeTypeNormalizer.normalizeToFullForm('someRandomNode'))
|
||||
.toBe('someRandomNode');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeWithDetails', () => {
|
||||
it('should return normalization details for full base form', () => {
|
||||
const result = NodeTypeNormalizer.normalizeWithDetails('n8n-nodes-base.webhook');
|
||||
|
||||
expect(result).toEqual({
|
||||
original: 'n8n-nodes-base.webhook',
|
||||
normalized: 'nodes-base.webhook',
|
||||
wasNormalized: true,
|
||||
package: 'base'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return normalization details for already short form', () => {
|
||||
const result = NodeTypeNormalizer.normalizeWithDetails('nodes-base.webhook');
|
||||
|
||||
expect(result).toEqual({
|
||||
original: 'nodes-base.webhook',
|
||||
normalized: 'nodes-base.webhook',
|
||||
wasNormalized: false,
|
||||
package: 'base'
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect langchain package', () => {
|
||||
const result = NodeTypeNormalizer.normalizeWithDetails('@n8n/n8n-nodes-langchain.agent');
|
||||
|
||||
expect(result).toEqual({
|
||||
original: '@n8n/n8n-nodes-langchain.agent',
|
||||
normalized: 'nodes-langchain.agent',
|
||||
wasNormalized: true,
|
||||
package: 'langchain'
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect community package', () => {
|
||||
const result = NodeTypeNormalizer.normalizeWithDetails('custom-package.myNode');
|
||||
|
||||
expect(result).toEqual({
|
||||
original: 'custom-package.myNode',
|
||||
normalized: 'custom-package.myNode',
|
||||
wasNormalized: false,
|
||||
package: 'community'
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect unknown package', () => {
|
||||
const result = NodeTypeNormalizer.normalizeWithDetails('unknownNode');
|
||||
|
||||
expect(result).toEqual({
|
||||
original: 'unknownNode',
|
||||
normalized: 'unknownNode',
|
||||
wasNormalized: false,
|
||||
package: 'unknown'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeBatch', () => {
|
||||
it('should normalize multiple node types', () => {
|
||||
const types = ['n8n-nodes-base.webhook', 'n8n-nodes-base.set', '@n8n/n8n-nodes-langchain.agent'];
|
||||
const result = NodeTypeNormalizer.normalizeBatch(types);
|
||||
|
||||
expect(result.size).toBe(3);
|
||||
expect(result.get('n8n-nodes-base.webhook')).toBe('nodes-base.webhook');
|
||||
expect(result.get('n8n-nodes-base.set')).toBe('nodes-base.set');
|
||||
expect(result.get('@n8n/n8n-nodes-langchain.agent')).toBe('nodes-langchain.agent');
|
||||
});
|
||||
|
||||
it('should handle empty array', () => {
|
||||
const result = NodeTypeNormalizer.normalizeBatch([]);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle mixed forms', () => {
|
||||
const types = [
|
||||
'n8n-nodes-base.webhook',
|
||||
'nodes-base.set',
|
||||
'@n8n/n8n-nodes-langchain.agent',
|
||||
'nodes-langchain.openAi'
|
||||
];
|
||||
const result = NodeTypeNormalizer.normalizeBatch(types);
|
||||
|
||||
expect(result.size).toBe(4);
|
||||
expect(result.get('n8n-nodes-base.webhook')).toBe('nodes-base.webhook');
|
||||
expect(result.get('nodes-base.set')).toBe('nodes-base.set');
|
||||
expect(result.get('@n8n/n8n-nodes-langchain.agent')).toBe('nodes-langchain.agent');
|
||||
expect(result.get('nodes-langchain.openAi')).toBe('nodes-langchain.openAi');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeWorkflowNodeTypes', () => {
|
||||
it('should normalize all nodes in workflow', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ type: 'n8n-nodes-base.webhook', id: '1', name: 'Webhook', parameters: {}, position: [0, 0] },
|
||||
{ type: 'n8n-nodes-base.set', id: '2', name: 'Set', parameters: {}, position: [100, 100] }
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow);
|
||||
|
||||
expect(result.nodes[0].type).toBe('nodes-base.webhook');
|
||||
expect(result.nodes[1].type).toBe('nodes-base.set');
|
||||
});
|
||||
|
||||
it('should preserve all other node properties', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
id: 'test-id',
|
||||
name: 'Test Webhook',
|
||||
parameters: { path: '/test' },
|
||||
position: [250, 300],
|
||||
credentials: { webhookAuth: { id: '1', name: 'Test' } }
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow);
|
||||
|
||||
expect(result.nodes[0]).toEqual({
|
||||
type: 'nodes-base.webhook', // normalized to short form
|
||||
id: 'test-id', // preserved
|
||||
name: 'Test Webhook', // preserved
|
||||
parameters: { path: '/test' }, // preserved
|
||||
position: [250, 300], // preserved
|
||||
credentials: { webhookAuth: { id: '1', name: 'Test' } } // preserved
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve workflow properties', () => {
|
||||
const workflow = {
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
nodes: [
|
||||
{ type: 'n8n-nodes-base.webhook', id: '1', name: 'Webhook', parameters: {}, position: [0, 0] }
|
||||
],
|
||||
connections: {
|
||||
'1': { main: [[{ node: '2', type: 'main', index: 0 }]] }
|
||||
}
|
||||
};
|
||||
|
||||
const result = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow);
|
||||
|
||||
expect(result.name).toBe('Test Workflow');
|
||||
expect(result.active).toBe(true);
|
||||
expect(result.connections).toEqual({
|
||||
'1': { main: [[{ node: '2', type: 'main', index: 0 }]] }
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle workflow without nodes', () => {
|
||||
const workflow = { connections: {} };
|
||||
const result = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow);
|
||||
expect(result).toEqual(workflow);
|
||||
});
|
||||
|
||||
it('should handle null workflow', () => {
|
||||
const result = NodeTypeNormalizer.normalizeWorkflowNodeTypes(null);
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle workflow with empty nodes array', () => {
|
||||
const workflow = { nodes: [], connections: {} };
|
||||
const result = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow);
|
||||
expect(result.nodes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFullForm', () => {
|
||||
it('should return true for full base form', () => {
|
||||
expect(NodeTypeNormalizer.isFullForm('n8n-nodes-base.webhook')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for full langchain form', () => {
|
||||
expect(NodeTypeNormalizer.isFullForm('@n8n/n8n-nodes-langchain.agent')).toBe(true);
|
||||
expect(NodeTypeNormalizer.isFullForm('n8n-nodes-langchain.agent')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for short base form', () => {
|
||||
expect(NodeTypeNormalizer.isFullForm('nodes-base.webhook')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for short langchain form', () => {
|
||||
expect(NodeTypeNormalizer.isFullForm('nodes-langchain.agent')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for community nodes', () => {
|
||||
expect(NodeTypeNormalizer.isFullForm('custom-package.myNode')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null/undefined', () => {
|
||||
expect(NodeTypeNormalizer.isFullForm(null as any)).toBe(false);
|
||||
expect(NodeTypeNormalizer.isFullForm(undefined as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isShortForm', () => {
|
||||
it('should return true for short base form', () => {
|
||||
expect(NodeTypeNormalizer.isShortForm('nodes-base.webhook')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for short langchain form', () => {
|
||||
expect(NodeTypeNormalizer.isShortForm('nodes-langchain.agent')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for full base form', () => {
|
||||
expect(NodeTypeNormalizer.isShortForm('n8n-nodes-base.webhook')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for full langchain form', () => {
|
||||
expect(NodeTypeNormalizer.isShortForm('@n8n/n8n-nodes-langchain.agent')).toBe(false);
|
||||
expect(NodeTypeNormalizer.isShortForm('n8n-nodes-langchain.agent')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for community nodes', () => {
|
||||
expect(NodeTypeNormalizer.isShortForm('custom-package.myNode')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null/undefined', () => {
|
||||
expect(NodeTypeNormalizer.isShortForm(null as any)).toBe(false);
|
||||
expect(NodeTypeNormalizer.isShortForm(undefined as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration scenarios', () => {
|
||||
it('should handle the critical use case from P0-R1', () => {
|
||||
// This is the exact scenario - normalize full form to match database
|
||||
const fullFormType = 'n8n-nodes-base.webhook'; // External source produces this
|
||||
const normalized = NodeTypeNormalizer.normalizeToFullForm(fullFormType);
|
||||
|
||||
expect(normalized).toBe('nodes-base.webhook'); // Database stores in short form
|
||||
});
|
||||
|
||||
it('should work correctly in a workflow validation scenario', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ type: 'n8n-nodes-base.webhook', id: '1', name: 'Webhook', parameters: {}, position: [0, 0] },
|
||||
{ type: 'n8n-nodes-base.httpRequest', id: '2', name: 'HTTP', parameters: {}, position: [200, 0] },
|
||||
{ type: 'nodes-base.set', id: '3', name: 'Set', parameters: {}, position: [400, 0] }
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const normalized = NodeTypeNormalizer.normalizeWorkflowNodeTypes(workflow);
|
||||
|
||||
// All node types should now be in short form for database lookup
|
||||
expect(normalized.nodes.every((n: any) => n.type.startsWith('nodes-base.'))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user