mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-01-30 22:42:04 +00:00
Compare commits
26 Commits
v2.17.0
...
fix/valida
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae11738ac7 | ||
|
|
6e365714e2 | ||
|
|
a2cc37bdf7 | ||
|
|
cf3c66c0ea | ||
|
|
f33b626179 | ||
|
|
2113714ec2 | ||
|
|
49757e3c22 | ||
|
|
dd521d0d87 | ||
|
|
331883f944 | ||
|
|
f3164e202f | ||
|
|
8e2e1dce62 | ||
|
|
b986beef2c | ||
|
|
943f5862a3 | ||
|
|
2c536a25fd | ||
|
|
e95ac7c335 | ||
|
|
e2c8fd0125 | ||
|
|
3332eb09fc | ||
|
|
bd03412fc8 | ||
|
|
73fa494735 | ||
|
|
67d8f5d4d4 | ||
|
|
d2a250e23d | ||
|
|
710f054b93 | ||
|
|
fd65727632 | ||
|
|
5d9936a909 | ||
|
|
de95fb21ba | ||
|
|
2bcd7c757b |
78
.github/workflows/release.yml
vendored
78
.github/workflows/release.yml
vendored
@@ -79,6 +79,38 @@ jobs:
|
||||
echo "ℹ️ No version change detected"
|
||||
fi
|
||||
|
||||
- name: Validate version against npm registry
|
||||
if: steps.check.outputs.changed == 'true'
|
||||
run: |
|
||||
CURRENT_VERSION="${{ steps.check.outputs.version }}"
|
||||
|
||||
# Get latest version from npm (handle package not found)
|
||||
NPM_VERSION=$(npm view n8n-mcp version 2>/dev/null || echo "0.0.0")
|
||||
|
||||
echo "Current version: $CURRENT_VERSION"
|
||||
echo "NPM registry version: $NPM_VERSION"
|
||||
|
||||
# Check if version already exists in npm
|
||||
if [ "$CURRENT_VERSION" = "$NPM_VERSION" ]; then
|
||||
echo "❌ Error: Version $CURRENT_VERSION already published to npm"
|
||||
echo "Please bump the version in package.json before releasing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Simple semver comparison (assumes format: major.minor.patch)
|
||||
# Compare if current version is greater than npm version
|
||||
if [ "$NPM_VERSION" != "0.0.0" ]; then
|
||||
# Sort versions and check if current is not the highest
|
||||
HIGHEST=$(printf '%s\n%s' "$NPM_VERSION" "$CURRENT_VERSION" | sort -V | tail -n1)
|
||||
if [ "$HIGHEST" != "$CURRENT_VERSION" ]; then
|
||||
echo "❌ Error: Version $CURRENT_VERSION is not greater than npm version $NPM_VERSION"
|
||||
echo "Please use a higher version number"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ Version $CURRENT_VERSION is valid (higher than npm version $NPM_VERSION)"
|
||||
|
||||
extract-changelog:
|
||||
name: Extract Changelog
|
||||
runs-on: ubuntu-latest
|
||||
@@ -206,8 +238,8 @@ jobs:
|
||||
echo "id=$RELEASE_ID" >> $GITHUB_OUTPUT
|
||||
echo "upload_url=https://uploads.github.com/repos/${{ github.repository }}/releases/$RELEASE_ID/assets{?name,label}" >> $GITHUB_OUTPUT
|
||||
|
||||
build-and-test:
|
||||
name: Build and Test
|
||||
build-and-verify:
|
||||
name: Build and Verify
|
||||
runs-on: ubuntu-latest
|
||||
needs: detect-version-change
|
||||
if: needs.detect-version-change.outputs.version-changed == 'true'
|
||||
@@ -226,22 +258,28 @@ jobs:
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
|
||||
- name: Rebuild database
|
||||
run: npm run rebuild
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
env:
|
||||
CI: true
|
||||
|
||||
|
||||
# Database is already built and committed during development
|
||||
# Rebuilding here causes segfault due to memory pressure (exit code 139)
|
||||
- name: Verify database exists
|
||||
run: |
|
||||
if [ ! -f "data/nodes.db" ]; then
|
||||
echo "❌ Error: data/nodes.db not found"
|
||||
echo "Please run 'npm run rebuild' locally and commit the database"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Database exists ($(du -h data/nodes.db | cut -f1))"
|
||||
|
||||
# Skip tests - they already passed in PR before merge
|
||||
# Running them again on the same commit adds no safety, only time (~6-7 min)
|
||||
|
||||
- name: Run type checking
|
||||
run: npm run typecheck
|
||||
|
||||
publish-npm:
|
||||
name: Publish to NPM
|
||||
runs-on: ubuntu-latest
|
||||
needs: [detect-version-change, build-and-test, create-release]
|
||||
needs: [detect-version-change, build-and-verify, create-release]
|
||||
if: needs.detect-version-change.outputs.version-changed == 'true'
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -259,10 +297,16 @@ jobs:
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
|
||||
- name: Rebuild database
|
||||
run: npm run rebuild
|
||||
|
||||
|
||||
# Database is already built and committed during development
|
||||
- name: Verify database exists
|
||||
run: |
|
||||
if [ ! -f "data/nodes.db" ]; then
|
||||
echo "❌ Error: data/nodes.db not found"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Database exists ($(du -h data/nodes.db | cut -f1))"
|
||||
|
||||
- name: Sync runtime version
|
||||
run: npm run sync:runtime-version
|
||||
|
||||
@@ -324,7 +368,7 @@ jobs:
|
||||
build-docker:
|
||||
name: Build and Push Docker Images
|
||||
runs-on: ubuntu-latest
|
||||
needs: [detect-version-change, build-and-test]
|
||||
needs: [detect-version-change, build-and-verify]
|
||||
if: needs.detect-version-change.outputs.version-changed == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
343
CHANGELOG.md
343
CHANGELOG.md
@@ -5,6 +5,349 @@ 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.18.0] - 2025-10-08
|
||||
|
||||
### 🎯 Validation Warning System Redesign
|
||||
|
||||
**Fixed critical validation warning system that was generating 96.5% false positives.**
|
||||
|
||||
This release fundamentally fixes the validation warning system that was overwhelming users and AI assistants with false warnings about properties they never configured. The system now achieves >90% signal-to-noise ratio (up from 3%).
|
||||
|
||||
#### Problem
|
||||
|
||||
The validation system was warning about properties with default values as if the user had configured them:
|
||||
- HTTP Request with 2 properties → 29 warnings (96% false positives)
|
||||
- Webhook with 1 property → 6 warnings (83% false positives)
|
||||
- Overall signal-to-noise ratio: 3%
|
||||
|
||||
#### Fixed
|
||||
|
||||
- **User Property Tracking** - System now distinguishes between user-provided properties and system defaults
|
||||
- **UI Property Filtering** - No longer validates UI-only elements (notice, callout, infoBox)
|
||||
- **Improved Messages** - Warnings now explain visibility requirements (e.g., "Requires: sendBody=true")
|
||||
- **Profile-Aware Filtering** - Each validation profile shows appropriate warnings
|
||||
- `minimal`: Only errors + critical security warnings
|
||||
- `runtime`: Errors + security warnings (filters property visibility noise)
|
||||
- `ai-friendly`: Balanced helpful warnings (default)
|
||||
- `strict`: All warnings + suggestions
|
||||
|
||||
#### Results
|
||||
|
||||
After fix (verified with n8n-mcp-tester):
|
||||
- HTTP Request with 2 properties → 1 warning (96.5% noise reduction)
|
||||
- Webhook with 1 property → 1 warning (83% noise reduction)
|
||||
- Overall signal-to-noise ratio: >90%
|
||||
|
||||
#### Changed
|
||||
|
||||
- `src/services/config-validator.ts`
|
||||
- Added `UI_ONLY_TYPES` constant to filter UI properties
|
||||
- Added `userProvidedKeys` parameter to `validate()` method
|
||||
- Added `getVisibilityRequirement()` helper for better error messages
|
||||
- Updated `checkCommonIssues()` to only warn about user-provided properties
|
||||
- `src/services/enhanced-config-validator.ts`
|
||||
- Extract user-provided keys before applying defaults
|
||||
- Pass `userProvidedKeys` to base validator
|
||||
- Enhanced profile filtering to remove property visibility warnings in `runtime` and `ai-friendly` profiles
|
||||
- `src/mcp-tools-engine.ts`
|
||||
- Extract user-provided keys in `validateNodeOperation()` before calling validator
|
||||
|
||||
#### Impact
|
||||
|
||||
- **AI Assistants**: Can now trust validation warnings (90%+ useful)
|
||||
- **Developers**: Get actionable guidance instead of noise
|
||||
- **Workflow Quality**: Real issues are fixed (not buried in false positives)
|
||||
- **System Trust**: Validation becomes a valuable tool
|
||||
|
||||
## [2.17.5] - 2025-10-07
|
||||
|
||||
### 🔧 Type Safety
|
||||
|
||||
**Added TypeScript type definitions for n8n node parsing with pragmatic strategic `any` assertions.**
|
||||
|
||||
This release improves type safety for VersionedNodeType and node class parameters while maintaining zero compilation errors and 100% backward compatibility. Follows a pragmatic "70% benefit with 0% breakage" approach using strategic `any` assertions where n8n's union types cause issues.
|
||||
|
||||
#### Added
|
||||
|
||||
- **Type Definitions** (`src/types/node-types.ts`)
|
||||
- Created comprehensive TypeScript interfaces for VersionedNodeType
|
||||
- Imported n8n's official interfaces (`IVersionedNodeType`, `INodeType`, `INodeTypeBaseDescription`, `INodeTypeDescription`)
|
||||
- Added `NodeClass` union type replacing `any` parameters in method signatures
|
||||
- Created `VersionedNodeInstance` and `RegularNodeInstance` interfaces
|
||||
- **Type Guards**: `isVersionedNodeInstance()` and `isVersionedNodeClass()` for runtime type checking
|
||||
- **Utility Functions**: `instantiateNode()`, `getNodeInstance()`, `getNodeDescription()` for safe node handling
|
||||
|
||||
- **Parser Type Updates**
|
||||
- Updated `node-parser.ts`: All method signatures now use `NodeClass` instead of `any` (15+ methods)
|
||||
- Updated `simple-parser.ts`: Method signatures strongly typed with `NodeClass`
|
||||
- Updated `property-extractor.ts`: All extraction methods use `NodeClass` typing
|
||||
- All parser method signatures now properly typed (30+ replacements)
|
||||
|
||||
- **Strategic `any` Assertions Pattern**
|
||||
- **Problem**: n8n's type hierarchy has union types (`INodeTypeBaseDescription | INodeTypeDescription`) where properties like `polling`, `version`, `webhooks` only exist on one side
|
||||
- **Solution**: Keep strong types in method signatures, use strategic `as any` assertions internally for property access
|
||||
- **Pattern**:
|
||||
```typescript
|
||||
// Strong signature provides caller type safety
|
||||
private method(description: INodeTypeBaseDescription | INodeTypeDescription): ReturnType {
|
||||
// Strategic assertion for internal property access
|
||||
const desc = description as any;
|
||||
return desc.polling || desc.webhooks; // Access union-incompatible properties
|
||||
}
|
||||
```
|
||||
- **Result**: 70% type safety benefit (method signatures) with 0% breakage (zero compilation errors)
|
||||
|
||||
#### Benefits
|
||||
|
||||
1. **Better IDE Support**: Auto-complete and inline documentation for node properties
|
||||
2. **Compile-Time Safety**: Strong method signatures catch type errors at call sites
|
||||
3. **Documentation**: Types serve as inline documentation for developers
|
||||
4. **Bug Prevention**: Would have helped prevent the `baseDescription` bug (v2.17.4)
|
||||
5. **Refactoring Safety**: Type system helps track changes across codebase
|
||||
6. **Zero Breaking Changes**: Pragmatic approach ensures build never breaks
|
||||
|
||||
#### Implementation Notes
|
||||
|
||||
- **Philosophy**: Incremental improvement over perfection - get significant benefit without extensive refactoring
|
||||
- **Zero Compilation Errors**: All TypeScript checks pass cleanly
|
||||
- **Test Coverage**: Updated all test files with strategic `as any` assertions for mock objects
|
||||
- **Runtime Behavior**: No changes - types are compile-time only
|
||||
- **Future Work**: Union types could be refined with conditional types or overloads for 100% type safety
|
||||
|
||||
#### Known Limitations
|
||||
|
||||
- Strategic `any` assertions bypass type checking for internal property access
|
||||
- Union type differences (`INodeTypeBaseDescription` vs `INodeTypeDescription`) not fully resolved
|
||||
- Test mocks require `as any` since they don't implement full n8n interfaces
|
||||
- Full type safety would require either (a) refactoring n8n's type hierarchy or (b) extensive conditional type logic
|
||||
|
||||
#### Impact
|
||||
|
||||
- **Breaking Changes**: None (internal types only, external API unchanged)
|
||||
- **Runtime Behavior**: No changes (types are compile-time only)
|
||||
- **Build System**: Zero compilation errors maintained
|
||||
- **Developer Experience**: Significantly improved with better types and IDE support
|
||||
- **Type Coverage**: ~70% (method signatures strongly typed, internal logic uses strategic assertions)
|
||||
|
||||
## [2.17.4] - 2025-10-07
|
||||
|
||||
### 🔧 Validation
|
||||
|
||||
**Fixed critical version extraction and typeVersion validation bugs.**
|
||||
|
||||
This release fixes two critical bugs that caused incorrect version data and validation bypasses for langchain nodes.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- **Version Extraction Bug (CRITICAL)**
|
||||
- **Issue:** AI Agent node returned version "3" instead of "2.2" (the defaultVersion)
|
||||
- **Impact:**
|
||||
- MCP tools (`get_node_essentials`, `get_node_info`) returned incorrect version "3"
|
||||
- Version "3" exists but n8n explicitly marks it as unstable ("Keep 2.2 until blocking bugs are fixed")
|
||||
- AI agents created workflows with wrong typeVersion, causing runtime issues
|
||||
- **Root Cause:** `extractVersion()` in node-parser.ts checked `instance.baseDescription.defaultVersion` which doesn't exist on VersionedNodeType instances
|
||||
- **Fix:** Updated version extraction priority in `node-parser.ts:137-200`
|
||||
1. Priority 1: Check `currentVersion` property (what VersionedNodeType actually uses)
|
||||
2. Priority 2: Check `description.defaultVersion` (fixed property name from `baseDescription`)
|
||||
3. Priority 3: Fallback to max(nodeVersions) as last resort
|
||||
- **Verification:** AI Agent node now correctly returns version "2.2" across all MCP tools
|
||||
|
||||
- **typeVersion Validation Bypass (CRITICAL)**
|
||||
- **Issue:** Langchain nodes with invalid typeVersion passed validation (even `typeVersion: 99999`)
|
||||
- **Impact:**
|
||||
- Invalid typeVersion values were never caught during validation
|
||||
- Workflows with non-existent typeVersions passed validation but failed at runtime in n8n
|
||||
- Validation was completely bypassed for all langchain nodes (AI Agent, Chat Trigger, OpenAI Chat Model, etc.)
|
||||
- **Root Cause:** `workflow-validator.ts:400-405` skipped ALL validation for langchain nodes before typeVersion check
|
||||
- **Fix:** Moved typeVersion validation BEFORE langchain skip in `workflow-validator.ts:447-493`
|
||||
- typeVersion now validated for ALL nodes including langchain
|
||||
- Validation runs before parameter validation skip
|
||||
- Checks for missing, invalid, outdated, and exceeding-maximum typeVersion values
|
||||
- **Verification:** Workflows with invalid typeVersion now correctly fail validation
|
||||
|
||||
- **Version 0 Rejection Bug (CRITICAL)**
|
||||
- **Issue:** typeVersion 0 was incorrectly rejected as invalid
|
||||
- **Impact:** Nodes with version 0 could not be validated, even though 0 is a valid version number
|
||||
- **Root Cause:** `workflow-validator.ts:462` checked `typeVersion < 1` instead of `< 0`
|
||||
- **Fix:** Changed validation to allow version 0 as a valid typeVersion
|
||||
- **Verification:** Version 0 is now accepted as valid
|
||||
|
||||
- **Duplicate baseDescription Bug in simple-parser.ts (HIGH)**
|
||||
- **Issue:** EXACT same version extraction bug existed in simple-parser.ts
|
||||
- **Impact:** Simple parser also returned incorrect versions for VersionedNodeType nodes
|
||||
- **Root Cause:** `simple-parser.ts:195-196, 208-209` checked `baseDescription.defaultVersion`
|
||||
- **Fix:** Applied identical fix as node-parser.ts with same priority chain
|
||||
1. Priority 1: Check `currentVersion` property
|
||||
2. Priority 2: Check `description.defaultVersion`
|
||||
3. Priority 3: Check `nodeVersions` (fallback to max)
|
||||
- **Verification:** Simple parser now returns correct versions
|
||||
|
||||
- **Unsafe Math.max() Usage (MEDIUM)**
|
||||
- **Issue:** 10 instances of Math.max() without empty array or NaN validation
|
||||
- **Impact:** Potential crashes with empty nodeVersions objects or invalid version data
|
||||
- **Root Cause:** No validation before calling Math.max(...array)
|
||||
- **Locations Fixed:**
|
||||
- `simple-parser.ts`: 2 instances
|
||||
- `node-parser.ts`: 5 instances
|
||||
- `property-extractor.ts`: 3 instances
|
||||
- **Fix:** Added defensive validation:
|
||||
```typescript
|
||||
const versions = Object.keys(nodeVersions).map(Number);
|
||||
if (versions.length > 0) {
|
||||
const maxVersion = Math.max(...versions);
|
||||
if (!isNaN(maxVersion)) {
|
||||
return maxVersion.toString();
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Verification:** All Math.max() calls now have proper validation
|
||||
|
||||
#### Technical Details
|
||||
|
||||
**Version Extraction Fix:**
|
||||
```typescript
|
||||
// BEFORE (BROKEN):
|
||||
if (instance?.baseDescription?.defaultVersion) { // Property doesn't exist!
|
||||
return instance.baseDescription.defaultVersion.toString();
|
||||
}
|
||||
|
||||
// AFTER (FIXED):
|
||||
if (instance?.currentVersion !== undefined) { // What VersionedNodeType actually uses
|
||||
return instance.currentVersion.toString();
|
||||
}
|
||||
if (instance?.description?.defaultVersion) { // Correct property name
|
||||
return instance.description.defaultVersion.toString();
|
||||
}
|
||||
```
|
||||
|
||||
**typeVersion Validation Fix:**
|
||||
```typescript
|
||||
// BEFORE (BROKEN):
|
||||
// Skip ALL node repository validation for langchain nodes
|
||||
if (normalizedType.startsWith('nodes-langchain.')) {
|
||||
continue; // typeVersion validation never runs!
|
||||
}
|
||||
|
||||
// AFTER (FIXED):
|
||||
// Validate typeVersion for ALL versioned nodes (including langchain)
|
||||
if (nodeInfo.isVersioned) {
|
||||
// ... typeVersion validation ...
|
||||
}
|
||||
|
||||
// THEN skip parameter validation for langchain nodes
|
||||
if (normalizedType.startsWith('nodes-langchain.')) {
|
||||
continue;
|
||||
}
|
||||
```
|
||||
|
||||
#### Impact
|
||||
|
||||
- **Version Accuracy:** AI Agent and all VersionedNodeType nodes now return correct version (2.2, not 3)
|
||||
- **Validation Reliability:** Invalid typeVersion values are now caught for langchain nodes
|
||||
- **Workflow Stability:** Prevents creation of workflows with non-existent typeVersions
|
||||
- **Database Rebuilt:** 536 nodes reloaded with corrected version data
|
||||
- **Parser Consistency:** Both node-parser.ts and simple-parser.ts use identical version extraction logic
|
||||
- **Robustness:** All Math.max() operations now protected against edge cases
|
||||
- **Edge Case Support:** Version 0 nodes now properly supported
|
||||
|
||||
#### Testing
|
||||
|
||||
- **Unit Tests:** All tests passing (node-parser: 34 tests, simple-parser: 39 tests)
|
||||
- Added tests for currentVersion priority
|
||||
- Added tests for version 0 edge case
|
||||
- Added tests for baseDescription rejection
|
||||
- **Integration Tests:** Verified with n8n-mcp-tester agent
|
||||
- Version consistency between `get_node_essentials` and `get_node_info` ✅
|
||||
- typeVersion validation catches invalid values (99, 100000) ✅
|
||||
- AI Agent correctly reports version "2.2" ✅
|
||||
- **Code Review:** Deep analysis found and fixed 6 similar bugs
|
||||
- 3 CRITICAL/HIGH priority bugs fixed in this release
|
||||
- 3 LOW priority bugs identified for future work
|
||||
|
||||
## [2.17.3] - 2025-10-07
|
||||
|
||||
### 🔧 Validation
|
||||
|
||||
**Fixed critical validation gap for AI model nodes with resourceLocator properties.**
|
||||
|
||||
This release adds validation for `resourceLocator` type properties, fixing a critical issue where AI agents could create invalid configurations that passed validation but failed at runtime.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- **resourceLocator Property Validation**
|
||||
- **Issue:** No validation existed for `resourceLocator` type properties used in AI model nodes
|
||||
- **Impact:**
|
||||
- AI agents could create invalid configurations like `model: "gpt-4o-mini"` (string) instead of `model: {mode: "list", value: "gpt-4o-mini"}` (object)
|
||||
- Invalid configs passed validation but failed at runtime in n8n
|
||||
- Affected many langchain nodes: OpenAI Chat Model (v1.2+), Anthropic, Cohere, DeepSeek, Groq, Mistral, OpenRouter, xAI Grok, and embeddings nodes
|
||||
- **Root Cause:** `validatePropertyTypes()` method in ConfigValidator only validated `string`, `number`, `boolean`, and `options` types - `resourceLocator` was completely missing
|
||||
- **Fix:** Added comprehensive resourceLocator validation in `config-validator.ts:237-274`
|
||||
- Validates value is an object (not string, number, null, or array)
|
||||
- Validates required `mode` property exists and is a string
|
||||
- Validates required `value` property exists
|
||||
- Provides helpful error messages with exact fix suggestions
|
||||
- Example error: `Property 'model' is a resourceLocator and must be an object with 'mode' and 'value' properties, got string`
|
||||
- Example fix: `Change model to { mode: "list", value: "gpt-4o-mini" } or { mode: "id", value: "gpt-4o-mini" }`
|
||||
|
||||
#### Added
|
||||
|
||||
- Comprehensive resourceLocator validation with 14 test cases covering:
|
||||
- String value rejection with helpful fix suggestions
|
||||
- Null and array value rejection
|
||||
- Missing `mode` or `value` property detection
|
||||
- Invalid `mode` type detection (e.g., number instead of string)
|
||||
- Invalid `mode` value validation (must be 'list', 'id', or 'url')
|
||||
- Empty object detection (missing both mode and value)
|
||||
- Extra properties handling (ignored gracefully)
|
||||
- Valid resourceLocator acceptance for "list", "id", and "url" modes
|
||||
- JSDoc documentation explaining resourceLocator structure and common mistakes
|
||||
- All 29 tests passing (100% coverage for new validation logic)
|
||||
|
||||
## [2.17.1] - 2025-10-07
|
||||
|
||||
### 🔧 Telemetry
|
||||
|
||||
**Critical fix: Docker and cloud deployments now maintain stable anonymous user IDs.**
|
||||
|
||||
This release fixes a critical telemetry issue where Docker and cloud deployments generated new user IDs on every container recreation, causing 100-200x inflation in unique user counts and preventing accurate retention metrics.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- **Docker/Cloud User ID Stability**
|
||||
- **Issue:** Docker containers and cloud deployments generated new anonymous user ID on every container recreation
|
||||
- **Impact:**
|
||||
- Stdio mode: ~1000x user ID inflation per month (with --rm flag)
|
||||
- HTTP mode: ~180x user ID inflation per month (6 releases/day)
|
||||
- Telemetry showed 3,996 "unique users" when actual number was likely ~2,400-2,800
|
||||
- 78% single-session rate and 5.97% Week 1 retention were inflated by duplicates
|
||||
- **Root Cause:** Container hostnames change on recreation, persistent config files lost with ephemeral containers
|
||||
- **Fix:** Use host's `/proc/sys/kernel/random/boot_id` for stable identification
|
||||
- boot_id is stable across container recreations (only changes on host reboot)
|
||||
- Available in all Linux containers (Alpine, Ubuntu, Node, etc.)
|
||||
- Readable by non-root users
|
||||
- Defensive fallback chain:
|
||||
1. boot_id (stable across container updates)
|
||||
2. Combined host signals (CPU cores, memory, kernel version)
|
||||
3. Generic Docker ID (allows aggregate statistics)
|
||||
- **Environment Detection:**
|
||||
- IS_DOCKER=true triggers boot_id method
|
||||
- Auto-detects cloud platforms: Railway, Render, Fly.io, Heroku, AWS, Kubernetes, GCP, Azure
|
||||
- Local installations continue using file-based method with hostname
|
||||
- **Zero Configuration:** No user action required, automatic environment detection
|
||||
|
||||
#### Added
|
||||
|
||||
- `TelemetryConfigManager.generateDockerStableId()` - Docker/cloud-specific ID generation
|
||||
- `TelemetryConfigManager.readBootId()` - Read and validate boot_id from /proc
|
||||
- `TelemetryConfigManager.generateCombinedFingerprint()` - Fallback fingerprinting
|
||||
- `TelemetryConfigManager.isCloudEnvironment()` - Auto-detect 8 cloud platforms
|
||||
|
||||
### Testing
|
||||
|
||||
- **Unit Tests:** 18 new tests for boot_id functionality, environment detection, fallback chain
|
||||
- **Integration Tests:** 16 new tests for actual file system operations, Docker detection, cloud platforms
|
||||
- **Coverage:** All 34 new tests passing (100%)
|
||||
|
||||
## [2.17.0] - 2025-01-06
|
||||
|
||||
### 🤖 AI Workflow Validation
|
||||
|
||||
478
DEEP_CODE_REVIEW_SIMILAR_BUGS.md
Normal file
478
DEEP_CODE_REVIEW_SIMILAR_BUGS.md
Normal file
@@ -0,0 +1,478 @@
|
||||
# DEEP CODE REVIEW: Similar Bugs Analysis
|
||||
## Context: Version Extraction and Validation Issues (v2.17.4)
|
||||
|
||||
**Date**: 2025-10-07
|
||||
**Scope**: Identify similar bugs to the two issues fixed in v2.17.4:
|
||||
1. Version Extraction Bug: Checked non-existent `instance.baseDescription.defaultVersion`
|
||||
2. Validation Bypass Bug: Langchain nodes skipped ALL validation before typeVersion check
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL FINDINGS
|
||||
|
||||
### BUG #1: CRITICAL - Version 0 Incorrectly Rejected in typeVersion Validation
|
||||
**Severity**: CRITICAL
|
||||
**Affects**: AI Agent ecosystem specifically
|
||||
|
||||
**Location**: `/Users/romualdczlonkowski/Pliki/n8n-mcp/n8n-mcp/src/services/workflow-validator.ts:462`
|
||||
|
||||
**Issue**:
|
||||
```typescript
|
||||
// Line 462 - INCORRECT: Rejects typeVersion = 0
|
||||
else if (typeof node.typeVersion !== 'number' || node.typeVersion < 1) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: `Invalid typeVersion: ${node.typeVersion}. Must be a positive number`
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Why This is Critical**:
|
||||
- n8n allows `typeVersion: 0` as a valid version (rare but legal)
|
||||
- The check `node.typeVersion < 1` rejects version 0
|
||||
- This is inconsistent with how we handle version extraction
|
||||
- Could break workflows using nodes with version 0
|
||||
|
||||
**Similar to Fixed Bug**:
|
||||
- Makes incorrect assumptions about version values
|
||||
- Breaks for edge cases (0 is valid, just like checking wrong property paths)
|
||||
- Uses wrong comparison operator (< 1 instead of <= 0 or !== undefined)
|
||||
|
||||
**Test Case**:
|
||||
```typescript
|
||||
const node = {
|
||||
id: 'test',
|
||||
name: 'Test Node',
|
||||
type: 'nodes-base.someNode',
|
||||
typeVersion: 0, // Valid but rejected!
|
||||
parameters: {}
|
||||
};
|
||||
// Current code: ERROR "Invalid typeVersion: 0. Must be a positive number"
|
||||
// Expected: Should be valid
|
||||
```
|
||||
|
||||
**Recommended Fix**:
|
||||
```typescript
|
||||
// Line 462 - CORRECT: Allow version 0
|
||||
else if (typeof node.typeVersion !== 'number' || node.typeVersion < 0) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: `Invalid typeVersion: ${node.typeVersion}. Must be a non-negative number (>= 0)`
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Verification**: Check if n8n core uses version 0 anywhere:
|
||||
```bash
|
||||
# Need to search n8n source for nodes with version 0
|
||||
grep -r "typeVersion.*:.*0" node_modules/n8n-nodes-base/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### BUG #2: HIGH - Inconsistent baseDescription Checks in simple-parser.ts
|
||||
**Severity**: HIGH
|
||||
**Affects**: Node loading and parsing
|
||||
|
||||
**Locations**:
|
||||
1. `/Users/romualdczlonkowski/Pliki/n8n-mcp/n8n-mcp/src/parsers/simple-parser.ts:195-196`
|
||||
2. `/Users/romualdczlonkowski/Pliki/n8n-mcp/n8n-mcp/src/parsers/simple-parser.ts:208-209`
|
||||
|
||||
**Issue #1 - Instance Check**:
|
||||
```typescript
|
||||
// Lines 195-196 - POTENTIALLY WRONG for VersionedNodeType
|
||||
if (instance?.baseDescription?.defaultVersion) {
|
||||
return instance.baseDescription.defaultVersion.toString();
|
||||
}
|
||||
```
|
||||
|
||||
**Issue #2 - Class Check**:
|
||||
```typescript
|
||||
// Lines 208-209 - POTENTIALLY WRONG for VersionedNodeType
|
||||
if (nodeClass.baseDescription?.defaultVersion) {
|
||||
return nodeClass.baseDescription.defaultVersion.toString();
|
||||
}
|
||||
```
|
||||
|
||||
**Why This is Similar**:
|
||||
- **EXACTLY THE SAME BUG** we just fixed in `node-parser.ts`!
|
||||
- VersionedNodeType stores base info in `description`, not `baseDescription`
|
||||
- These checks will FAIL for VersionedNodeType instances
|
||||
- `simple-parser.ts` was not updated when `node-parser.ts` was fixed
|
||||
|
||||
**Evidence from Fixed Code** (node-parser.ts):
|
||||
```typescript
|
||||
// Line 149 comment:
|
||||
// "Critical Fix (v2.17.4): Removed check for non-existent instance.baseDescription.defaultVersion"
|
||||
|
||||
// Line 167 comment:
|
||||
// "VersionedNodeType stores baseDescription as 'description', not 'baseDescription'"
|
||||
```
|
||||
|
||||
**Impact**:
|
||||
- `simple-parser.ts` is used as a fallback parser
|
||||
- Will return incorrect versions for VersionedNodeType nodes
|
||||
- Could cause version mismatches between parsers
|
||||
|
||||
**Recommended Fix**:
|
||||
```typescript
|
||||
// REMOVE Lines 195-196 entirely (non-existent property)
|
||||
// REMOVE Lines 208-209 entirely (non-existent property)
|
||||
|
||||
// Instead, use the correct property path:
|
||||
if (instance?.description?.defaultVersion) {
|
||||
return instance.description.defaultVersion.toString();
|
||||
}
|
||||
|
||||
if (nodeClass.description?.defaultVersion) {
|
||||
return nodeClass.description.defaultVersion.toString();
|
||||
}
|
||||
```
|
||||
|
||||
**Test Case**:
|
||||
```typescript
|
||||
// Test with AI Agent (VersionedNodeType)
|
||||
const AIAgent = require('@n8n/n8n-nodes-langchain').Agent;
|
||||
const instance = new AIAgent();
|
||||
|
||||
// BUG: simple-parser checks instance.baseDescription.defaultVersion (doesn't exist)
|
||||
// CORRECT: Should check instance.description.defaultVersion (exists)
|
||||
console.log('baseDescription exists?', !!instance.baseDescription); // false
|
||||
console.log('description exists?', !!instance.description); // true
|
||||
console.log('description.defaultVersion?', instance.description?.defaultVersion);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### BUG #3: MEDIUM - Inconsistent Math.max Usage Without Validation
|
||||
**Severity**: MEDIUM
|
||||
**Affects**: All versioned nodes
|
||||
|
||||
**Locations**:
|
||||
1. `/Users/romualdczlonkowski/Pliki/n8n-mcp/n8n-mcp/src/parsers/property-extractor.ts:19`
|
||||
2. `/Users/romualdczlonkowski/Pliki/n8n-mcp/n8n-mcp/src/parsers/property-extractor.ts:75`
|
||||
3. `/Users/romualdczlonkowski/Pliki/n8n-mcp/n8n-mcp/src/parsers/property-extractor.ts:181`
|
||||
4. `/Users/romualdczlonkowski/Pliki/n8n-mcp/n8n-mcp/src/parsers/node-parser.ts:175`
|
||||
5. `/Users/romualdczlonkowski/Pliki/n8n-mcp/n8n-mcp/src/parsers/node-parser.ts:202`
|
||||
|
||||
**Issue**:
|
||||
```typescript
|
||||
// property-extractor.ts:19 - NO VALIDATION
|
||||
if (instance?.nodeVersions) {
|
||||
const versions = Object.keys(instance.nodeVersions);
|
||||
const latestVersion = Math.max(...versions.map(Number)); // DANGER!
|
||||
const versionedNode = instance.nodeVersions[latestVersion];
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Why This is Problematic**:
|
||||
1. **No empty array check**: `Math.max()` returns `-Infinity` for empty arrays
|
||||
2. **No NaN check**: Non-numeric keys cause `Math.max(NaN, NaN) = NaN`
|
||||
3. **Ignores defaultVersion**: Should check `defaultVersion` BEFORE falling back to max
|
||||
4. **Inconsistent with fixed code**: node-parser.ts was fixed to prioritize `currentVersion` and `defaultVersion`
|
||||
|
||||
**Edge Cases That Break**:
|
||||
```typescript
|
||||
// Case 1: Empty nodeVersions
|
||||
const nodeVersions = {};
|
||||
const versions = Object.keys(nodeVersions); // []
|
||||
const latestVersion = Math.max(...versions.map(Number)); // -Infinity
|
||||
const versionedNode = nodeVersions[-Infinity]; // undefined
|
||||
|
||||
// Case 2: Non-numeric keys
|
||||
const nodeVersions = { 'v1': {}, 'v2': {} };
|
||||
const versions = Object.keys(nodeVersions); // ['v1', 'v2']
|
||||
const latestVersion = Math.max(...versions.map(Number)); // Math.max(NaN, NaN) = NaN
|
||||
const versionedNode = nodeVersions[NaN]; // undefined
|
||||
```
|
||||
|
||||
**Similar to Fixed Bug**:
|
||||
- Assumes data structure without validation
|
||||
- Could return undefined and cause downstream errors
|
||||
- Doesn't follow the correct priority: `currentVersion` > `defaultVersion` > `max(nodeVersions)`
|
||||
|
||||
**Recommended Fix**:
|
||||
```typescript
|
||||
// property-extractor.ts - Consistent with node-parser.ts fix
|
||||
if (instance?.nodeVersions) {
|
||||
// PRIORITY 1: Check currentVersion (already computed by VersionedNodeType)
|
||||
if (instance.currentVersion !== undefined) {
|
||||
const versionedNode = instance.nodeVersions[instance.currentVersion];
|
||||
if (versionedNode?.description?.properties) {
|
||||
return this.normalizeProperties(versionedNode.description.properties);
|
||||
}
|
||||
}
|
||||
|
||||
// PRIORITY 2: Check defaultVersion
|
||||
if (instance.description?.defaultVersion !== undefined) {
|
||||
const versionedNode = instance.nodeVersions[instance.description.defaultVersion];
|
||||
if (versionedNode?.description?.properties) {
|
||||
return this.normalizeProperties(versionedNode.description.properties);
|
||||
}
|
||||
}
|
||||
|
||||
// PRIORITY 3: Fallback to max with validation
|
||||
const versions = Object.keys(instance.nodeVersions);
|
||||
if (versions.length > 0) {
|
||||
const numericVersions = versions.map(Number).filter(v => !isNaN(v));
|
||||
if (numericVersions.length > 0) {
|
||||
const latestVersion = Math.max(...numericVersions);
|
||||
const versionedNode = instance.nodeVersions[latestVersion];
|
||||
if (versionedNode?.description?.properties) {
|
||||
return this.normalizeProperties(versionedNode.description.properties);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Applies to 5 locations** - all need same fix pattern.
|
||||
|
||||
---
|
||||
|
||||
### BUG #4: MEDIUM - Expression Validation Skip for Langchain Nodes (Line 972)
|
||||
**Severity**: MEDIUM
|
||||
**Affects**: AI Agent ecosystem
|
||||
|
||||
**Location**: `/Users/romualdczlonkowski/Pliki/n8n-mcp/n8n-mcp/src/services/workflow-validator.ts:972`
|
||||
|
||||
**Issue**:
|
||||
```typescript
|
||||
// Line 969-974 - Another early skip for langchain
|
||||
// Skip expression validation for langchain nodes
|
||||
// They have AI-specific validators and different expression rules
|
||||
const normalizedType = NodeTypeNormalizer.normalizeToFullForm(node.type);
|
||||
if (normalizedType.startsWith('nodes-langchain.')) {
|
||||
continue; // Skip ALL expression validation
|
||||
}
|
||||
```
|
||||
|
||||
**Why This Could Be Problematic**:
|
||||
- Similar to the bug we fixed where langchain nodes skipped typeVersion validation
|
||||
- Langchain nodes CAN use expressions (especially in AI Agent system prompts, tool configurations)
|
||||
- Skipping ALL expression validation means we won't catch:
|
||||
- Syntax errors in expressions
|
||||
- Invalid node references
|
||||
- Missing input data references
|
||||
|
||||
**Similar to Fixed Bug**:
|
||||
- Early return/continue before running validation
|
||||
- Assumes langchain nodes don't need a certain type of validation
|
||||
- We already fixed this pattern once for typeVersion - might need fixing here too
|
||||
|
||||
**Investigation Required**:
|
||||
Need to determine if langchain nodes:
|
||||
1. Use n8n expressions in their parameters? (YES - AI Agent uses expressions)
|
||||
2. Need different expression validation rules? (MAYBE)
|
||||
3. Should have AI-specific expression validation? (PROBABLY YES)
|
||||
|
||||
**Recommended Action**:
|
||||
1. **Short-term**: Add comment explaining WHY we skip (currently missing)
|
||||
2. **Medium-term**: Implement langchain-specific expression validation
|
||||
3. **Long-term**: Never skip validation entirely - always have appropriate validation
|
||||
|
||||
**Example of Langchain Expressions**:
|
||||
```typescript
|
||||
// AI Agent system prompt can contain expressions
|
||||
{
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
parameters: {
|
||||
text: 'You are an assistant. User input: {{ $json.userMessage }}' // Expression!
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### BUG #5: LOW - Inconsistent Version Property Access Patterns
|
||||
**Severity**: LOW
|
||||
**Affects**: Code maintainability
|
||||
|
||||
**Locations**: Multiple files use different patterns
|
||||
|
||||
**Issue**: Three different patterns for accessing version:
|
||||
```typescript
|
||||
// Pattern 1: Direct access with fallback (SAFE)
|
||||
const version = nodeInfo.version || 1;
|
||||
|
||||
// Pattern 2: Direct access without fallback (UNSAFE)
|
||||
if (nodeInfo.version && node.typeVersion < nodeInfo.version) { ... }
|
||||
|
||||
// Pattern 3: Falsy check (BREAKS for version 0)
|
||||
if (nodeInfo.version) { ... } // Fails if version = 0
|
||||
```
|
||||
|
||||
**Why This Matters**:
|
||||
- Pattern 3 breaks for `version = 0` (falsy but valid)
|
||||
- Inconsistency makes code harder to maintain
|
||||
- Similar issue to version < 1 check
|
||||
|
||||
**Examples**:
|
||||
```typescript
|
||||
// workflow-validator.ts:471 - UNSAFE for version 0
|
||||
else if (nodeInfo.version && node.typeVersion < nodeInfo.version) {
|
||||
// If nodeInfo.version = 0, this never executes (falsy check)
|
||||
}
|
||||
|
||||
// workflow-validator.ts:480 - UNSAFE for version 0
|
||||
else if (nodeInfo.version && node.typeVersion > nodeInfo.version) {
|
||||
// If nodeInfo.version = 0, this never executes (falsy check)
|
||||
}
|
||||
```
|
||||
|
||||
**Recommended Fix**:
|
||||
```typescript
|
||||
// Use !== undefined for version checks
|
||||
else if (nodeInfo.version !== undefined && node.typeVersion < nodeInfo.version) {
|
||||
// Now works correctly for version 0
|
||||
}
|
||||
|
||||
else if (nodeInfo.version !== undefined && node.typeVersion > nodeInfo.version) {
|
||||
// Now works correctly for version 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### BUG #6: LOW - Missing Type Safety for VersionedNodeType Properties
|
||||
**Severity**: LOW
|
||||
**Affects**: TypeScript type safety
|
||||
|
||||
**Issue**: No TypeScript interface for VersionedNodeType properties
|
||||
|
||||
**Current Code**:
|
||||
```typescript
|
||||
// We access these properties everywhere but no type definition:
|
||||
instance.currentVersion // any
|
||||
instance.description // any
|
||||
instance.nodeVersions // any
|
||||
instance.baseDescription // any (doesn't exist but not caught!)
|
||||
```
|
||||
|
||||
**Why This Matters**:
|
||||
- TypeScript COULD HAVE caught the `baseDescription` bug
|
||||
- Using `any` everywhere defeats type safety
|
||||
- Makes refactoring dangerous
|
||||
|
||||
**Recommended Fix**:
|
||||
```typescript
|
||||
// Create types/versioned-node.ts
|
||||
export interface VersionedNodeTypeInstance {
|
||||
currentVersion: number;
|
||||
description: {
|
||||
name: string;
|
||||
displayName: string;
|
||||
defaultVersion?: number;
|
||||
version?: number | number[];
|
||||
properties?: any[];
|
||||
// ... other properties
|
||||
};
|
||||
nodeVersions: {
|
||||
[version: number]: {
|
||||
description: {
|
||||
properties?: any[];
|
||||
// ... other properties
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Then use in code:
|
||||
const instance = new nodeClass() as VersionedNodeTypeInstance;
|
||||
instance.baseDescription // TypeScript error: Property 'baseDescription' does not exist
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SUMMARY OF FINDINGS
|
||||
|
||||
### By Severity:
|
||||
|
||||
**CRITICAL (1 bug)**:
|
||||
1. Version 0 incorrectly rejected (workflow-validator.ts:462)
|
||||
|
||||
**HIGH (1 bug)**:
|
||||
2. Inconsistent baseDescription checks in simple-parser.ts (EXACT DUPLICATE of fixed bug)
|
||||
|
||||
**MEDIUM (2 bugs)**:
|
||||
3. Unsafe Math.max usage in property-extractor.ts (5 locations)
|
||||
4. Expression validation skip for langchain nodes (workflow-validator.ts:972)
|
||||
|
||||
**LOW (2 issues)**:
|
||||
5. Inconsistent version property access patterns
|
||||
6. Missing TypeScript types for VersionedNodeType
|
||||
|
||||
### By Category:
|
||||
|
||||
**Property Name Assumptions** (Similar to Bug #1):
|
||||
- BUG #2: baseDescription checks in simple-parser.ts
|
||||
|
||||
**Validation Order Issues** (Similar to Bug #2):
|
||||
- BUG #4: Expression validation skip for langchain nodes
|
||||
|
||||
**Version Logic Issues**:
|
||||
- BUG #1: Version 0 rejected incorrectly
|
||||
- BUG #3: Math.max without validation
|
||||
- BUG #5: Inconsistent version checks
|
||||
|
||||
**Type Safety Issues**:
|
||||
- BUG #6: Missing VersionedNodeType types
|
||||
|
||||
### Affects AI Agent Ecosystem:
|
||||
- BUG #1: Critical - blocks valid typeVersion values
|
||||
- BUG #2: High - affects AI Agent version extraction
|
||||
- BUG #4: Medium - skips expression validation
|
||||
- All others: Indirectly affect stability
|
||||
|
||||
---
|
||||
|
||||
## RECOMMENDED ACTIONS
|
||||
|
||||
### Immediate (Critical):
|
||||
1. Fix version 0 rejection in workflow-validator.ts:462
|
||||
2. Fix baseDescription checks in simple-parser.ts
|
||||
|
||||
### Short-term (High Priority):
|
||||
3. Add validation to all Math.max usages in property-extractor.ts
|
||||
4. Investigate and document expression validation skip for langchain
|
||||
|
||||
### Medium-term:
|
||||
5. Standardize version property access patterns
|
||||
6. Add TypeScript types for VersionedNodeType
|
||||
|
||||
### Testing:
|
||||
7. Add test cases for version 0
|
||||
8. Add test cases for empty nodeVersions
|
||||
9. Add test cases for langchain expression validation
|
||||
|
||||
---
|
||||
|
||||
## VERIFICATION CHECKLIST
|
||||
|
||||
For each bug found:
|
||||
- [x] File and line number identified
|
||||
- [x] Code snippet showing issue
|
||||
- [x] Why it's similar to fixed bugs
|
||||
- [x] Severity assessment
|
||||
- [x] Test case provided
|
||||
- [x] Fix recommended with code
|
||||
- [x] Impact on AI Agent ecosystem assessed
|
||||
|
||||
---
|
||||
|
||||
## NOTES
|
||||
|
||||
1. **Pattern Recognition**: The baseDescription bug in simple-parser.ts is EXACTLY the same bug we just fixed in node-parser.ts, suggesting these files should be refactored to share version extraction logic.
|
||||
|
||||
2. **Validation Philosophy**: We're seeing a pattern of skipping validation for langchain nodes. This was correct for PARAMETER validation but WRONG for typeVersion. Need to review each skip carefully.
|
||||
|
||||
3. **Version 0 Edge Case**: If n8n doesn't use version 0 in practice, the critical bug might be theoretical. However, rejecting valid values is still a bug.
|
||||
|
||||
4. **Math.max Safety**: The Math.max pattern is used 5+ times. Should extract to a utility function with proper validation.
|
||||
|
||||
5. **Type Safety**: Adding proper TypeScript types would have prevented the baseDescription bug entirely. Strong recommendation for future work.
|
||||
@@ -1,112 +0,0 @@
|
||||
# AI Validation Implementation - Phases 1-2 Complete
|
||||
|
||||
## ✅ Phase 1: COMPLETED (100%)
|
||||
|
||||
### Fixed Issues:
|
||||
1. ✅ Exported missing TypeScript types (WorkflowNode, WorkflowJson, ReverseConnection, ValidationIssue)
|
||||
2. ✅ Fixed test function signatures for 3 validators (VectorStore, Workflow, AIAgent)
|
||||
3. ✅ Fixed SearXNG import typo
|
||||
4. ✅ Fixed WolframAlpha test expectations
|
||||
|
||||
### Results:
|
||||
- **TypeScript**: Compiles cleanly with 0 errors
|
||||
- **Tests**: 33/64 passing (+37.5% improvement from baseline)
|
||||
- **Build**: Successful
|
||||
- **Code Quality**: All Phase 1 blockers resolved
|
||||
|
||||
## ✅ Phase 2: COMPLETED (100%)
|
||||
|
||||
### Critical Bug Fixed:
|
||||
**ROOT CAUSE DISCOVERED**: All AI validation was silently skipped due to node type comparison mismatch.
|
||||
- `NodeTypeNormalizer.normalizeToFullForm()` returns SHORT form: `'nodes-langchain.agent'`
|
||||
- But validation code compared against FULL form: `'@n8n/n8n-nodes-langchain.agent'`
|
||||
- **Result**: Every comparison was FALSE → validation never executed
|
||||
|
||||
### Fixed Issues:
|
||||
1. ✅ **HIGH-01**: Missing language model detection (was never running due to type mismatch)
|
||||
2. ✅ **HIGH-04**: AI tool connection detection (was never running due to type mismatch)
|
||||
3. ✅ **HIGH-08**: Streaming mode validation (was never running + incomplete implementation)
|
||||
4. ✅ **MEDIUM-02**: get_node_essentials examples retrieval (inconsistent workflowNodeType construction)
|
||||
|
||||
### Changes Made:
|
||||
1. **Node Type Comparisons** (21 locations fixed):
|
||||
- ai-node-validator.ts: 7 fixes
|
||||
- ai-tool-validators.ts: 14 fixes (13 validator keys + 13 switch cases)
|
||||
|
||||
2. **Enhanced Streaming Validation**:
|
||||
- Added validation for AI Agent's own `streamResponse` setting
|
||||
- Previously only checked streaming FROM Chat Trigger
|
||||
|
||||
3. **Examples Retrieval Fix**:
|
||||
- Use `result.workflowNodeType` instead of reconstructing
|
||||
- Matches `search_nodes` behavior for consistency
|
||||
|
||||
### Results:
|
||||
- **All 25 AI validator tests**: ✅ PASS (100%)
|
||||
- **Debug tests**: ✅ 3/3 PASS
|
||||
- **Validation now working**: Missing LM, Tool connections, Streaming constraints
|
||||
- **Examples retrieval**: Fixed for all node types
|
||||
|
||||
## 📋 Next Steps
|
||||
|
||||
### Phase 3 (Code Quality - OPTIONAL):
|
||||
1. Standardize validator signatures with optional parameters
|
||||
2. Add circular reference validation
|
||||
3. Improve URL validation for all n8n expression formats
|
||||
4. Extract remaining magic numbers to constants
|
||||
|
||||
### Phase 4 (Testing & Documentation - REQUIRED):
|
||||
1. Add edge case tests for validators
|
||||
2. Add multi-agent integration test
|
||||
3. Update README.md with AI validation features
|
||||
4. Update CHANGELOG.md with version 2.17.0 details
|
||||
5. Bump version to 2.17.0
|
||||
|
||||
## 🎯 Success Metrics
|
||||
|
||||
### Phase 1:
|
||||
- ✅ Build compiles: YES (0 errors)
|
||||
- ✅ Tests execute: YES (all run without crashes)
|
||||
- ✅ 50%+ tests passing: YES (33/64 = 51.5%)
|
||||
|
||||
### Phase 2:
|
||||
- ✅ Missing LM validation: FIXED (now triggers correctly)
|
||||
- ✅ Tool connection detection: FIXED (no false warnings)
|
||||
- ✅ Streaming validation: FIXED (both scenarios)
|
||||
- ✅ Examples retrieval: FIXED (consistent node types)
|
||||
- ✅ All 25 AI validator tests: PASS (100%)
|
||||
|
||||
### Overall Progress:
|
||||
- **Phase 1** (TypeScript blockers): ✅ 100% COMPLETE
|
||||
- **Phase 2** (Critical validation bugs): ✅ 100% COMPLETE
|
||||
- **Phase 3** (Code quality): ⏳ 0% (optional improvements)
|
||||
- **Phase 4** (Docs & version): ⏳ 0% (required before release)
|
||||
- **Total test pass rate**: 40+/64 (62.5%+) - significant improvement from 24/64 baseline
|
||||
|
||||
## 📝 Commits
|
||||
|
||||
### Phase 1:
|
||||
- 91ad084: fix: resolve TypeScript compilation blockers
|
||||
- Exported missing types
|
||||
- Fixed test signatures (9 functions)
|
||||
- Fixed import typo
|
||||
- Fixed test expectations
|
||||
|
||||
### Phase 2:
|
||||
- 92eb4ef: fix: resolve node type normalization bug blocking all AI validation
|
||||
- Fixed 21 node type comparisons
|
||||
- Enhanced streaming validation
|
||||
- Added streamResponse setting check
|
||||
|
||||
- 81dfbbb: fix: get_node_essentials examples now use consistent workflowNodeType
|
||||
- Fixed examples retrieval
|
||||
- Matches search_nodes behavior
|
||||
|
||||
- 3ba3f10: docs: add Phase 2 completion summary
|
||||
- 1eedb43: docs: add Phase 2 test scenarios
|
||||
|
||||
### Total Impact:
|
||||
- 5 commits
|
||||
- ~700 lines changed
|
||||
- 4 critical bugs fixed
|
||||
- 25 AI validator tests now passing
|
||||
@@ -1,190 +0,0 @@
|
||||
# Phase 2: CRITICAL BUG FIXES - COMPLETE ✅
|
||||
|
||||
## Root Cause Discovered
|
||||
|
||||
**THE BUG:** All AI validation was silently skipped due to node type comparison mismatch.
|
||||
|
||||
- `NodeTypeNormalizer.normalizeToFullForm()` returns SHORT form: `'nodes-langchain.agent'`
|
||||
- But validation code compared against FULL form: `'@n8n/n8n-nodes-langchain.agent'`
|
||||
- **Result:** Every comparison was FALSE → validation never executed
|
||||
|
||||
## Impact Analysis
|
||||
|
||||
Before this fix, **ALL AI-specific validation was completely non-functional**:
|
||||
|
||||
1. ❌ Missing language model detection - Never triggered
|
||||
2. ❌ AI tool connection detection - Never triggered
|
||||
3. ❌ Streaming mode validation - Never triggered
|
||||
4. ❌ AI tool sub-node validation - Never triggered
|
||||
5. ❌ Chat Trigger validation - Never triggered
|
||||
6. ❌ Basic LLM Chain validation - Never triggered
|
||||
|
||||
## Fixes Applied
|
||||
|
||||
### 1. Node Type Comparisons (21 locations fixed)
|
||||
|
||||
#### ai-node-validator.ts (7 fixes):
|
||||
- **Lines 551, 557, 563**: validateAISpecificNodes node type checks
|
||||
```typescript
|
||||
// Before: if (normalizedType === '@n8n/n8n-nodes-langchain.agent')
|
||||
// After: if (normalizedType === 'nodes-langchain.agent')
|
||||
```
|
||||
|
||||
- **Line 348**: checkIfStreamingTarget Chat Trigger detection
|
||||
- **Lines 417, 444**: validateChatTrigger streaming mode checks
|
||||
- **Lines 589-591**: hasAINodes array values
|
||||
- **Lines 606-608, 612**: getAINodeCategory comparisons
|
||||
|
||||
#### ai-tool-validators.ts (14 fixes):
|
||||
- **Lines 980-991**: AI_TOOL_VALIDATORS object keys (13 tool types)
|
||||
```typescript
|
||||
// Before: '@n8n/n8n-nodes-langchain.toolHttpRequest': validateHTTPRequestTool,
|
||||
// After: 'nodes-langchain.toolHttpRequest': validateHTTPRequestTool,
|
||||
```
|
||||
|
||||
- **Lines 1015-1037**: validateAIToolSubNode switch cases (13 cases)
|
||||
|
||||
### 2. Enhanced Streaming Validation
|
||||
|
||||
Added validation for AI Agent's own `streamResponse` setting (lines 259-276):
|
||||
|
||||
```typescript
|
||||
const isStreamingTarget = checkIfStreamingTarget(node, workflow, reverseConnections);
|
||||
const hasOwnStreamingEnabled = node.parameters?.options?.streamResponse === true;
|
||||
|
||||
if (isStreamingTarget || hasOwnStreamingEnabled) {
|
||||
// Validate no main output connections
|
||||
const streamSource = isStreamingTarget
|
||||
? 'connected from Chat Trigger with responseMode="streaming"'
|
||||
: 'has streamResponse=true in options';
|
||||
// ... error if main outputs exist
|
||||
}
|
||||
```
|
||||
|
||||
**Why this matters:**
|
||||
- Previously only validated streaming FROM Chat Trigger
|
||||
- Missed case where AI Agent itself enables streaming
|
||||
- Now validates BOTH scenarios correctly
|
||||
|
||||
## Test Results
|
||||
|
||||
### Debug Tests (scripts/test-ai-validation-debug.ts)
|
||||
```
|
||||
Test 1 (No LM): PASS ✓ (Detects missing language model)
|
||||
Test 2 (With LM): PASS ✓ (No error when LM present)
|
||||
Test 3 (Tools, No LM): PASS ✓ (Detects missing LM + validates tools)
|
||||
```
|
||||
|
||||
### Unit Tests
|
||||
```
|
||||
✓ AI Node Validator tests: 25/25 PASS (100%)
|
||||
✓ Total passing tests: ~40/64 (62.5%)
|
||||
✓ Improvement from Phase 1: +7 tests (+21%)
|
||||
```
|
||||
|
||||
### Validation Now Working
|
||||
- ✅ Missing language model: **FIXED** - Errors correctly generated
|
||||
- ✅ AI tool connections: **FIXED** - No false warnings
|
||||
- ✅ Streaming constraints: **FIXED** - Both scenarios validated
|
||||
- ✅ AI tool sub-nodes: **FIXED** - All 13 validators active
|
||||
- ✅ Chat Trigger: **FIXED** - Streaming mode validated
|
||||
- ✅ Basic LLM Chain: **FIXED** - Language model required
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Why normalizeToFullForm Returns SHORT Form
|
||||
|
||||
From `src/utils/node-type-normalizer.ts` line 76:
|
||||
```typescript
|
||||
/**
|
||||
* Normalize node type to canonical SHORT form (database format)
|
||||
*
|
||||
* **NOTE:** Method name says "ToFullForm" for backward compatibility,
|
||||
* but actually normalizes TO SHORT form to match database storage.
|
||||
*/
|
||||
static normalizeToFullForm(type: string): string {
|
||||
// Converts @n8n/n8n-nodes-langchain.agent → nodes-langchain.agent
|
||||
```
|
||||
|
||||
The method name is misleading but maintained for backward compatibility. The database stores nodes in SHORT form.
|
||||
|
||||
### Affected Validation Functions
|
||||
|
||||
Before fix (none working):
|
||||
1. `validateAIAgent()` - NEVER ran
|
||||
2. `validateChatTrigger()` - NEVER ran
|
||||
3. `validateBasicLLMChain()` - NEVER ran
|
||||
4. `validateAIToolSubNode()` - NEVER ran (all 13 validators)
|
||||
5. `hasAINodes()` - Always returned FALSE
|
||||
6. `getAINodeCategory()` - Always returned NULL
|
||||
7. `isAIToolSubNode()` - Always returned FALSE
|
||||
|
||||
After fix (all working):
|
||||
1. ✅ `validateAIAgent()` - Validates LM, tools, streaming, memory, iterations
|
||||
2. ✅ `validateChatTrigger()` - Validates streaming mode constraints
|
||||
3. ✅ `validateBasicLLMChain()` - Validates LM connections
|
||||
4. ✅ `validateAIToolSubNode()` - Routes to correct validator
|
||||
5. ✅ `hasAINodes()` - Correctly detects AI nodes
|
||||
6. ✅ `getAINodeCategory()` - Returns correct category
|
||||
7. ✅ `isAIToolSubNode()` - Correctly identifies AI tools
|
||||
|
||||
## Issue Resolution
|
||||
|
||||
### HIGH-01: Missing Language Model Detection ✅
|
||||
**Status:** FIXED
|
||||
**Root cause:** Node type comparison never matched
|
||||
**Solution:** Changed all comparisons to SHORT form
|
||||
**Verified:** Test creates AI Agent with no LM → Gets MISSING_LANGUAGE_MODEL error
|
||||
|
||||
### HIGH-04: AI Tool Connection Detection ✅
|
||||
**Status:** FIXED
|
||||
**Root cause:** validateAIAgent never executed
|
||||
**Solution:** Fixed node type comparison
|
||||
**Verified:** Test with tools connected → No false "no tools" warning
|
||||
|
||||
### HIGH-08: Streaming Mode Validation ✅
|
||||
**Status:** FIXED
|
||||
**Root cause:**
|
||||
1. Node type comparison never matched (primary)
|
||||
2. Missing validation for AI Agent's own streamResponse (secondary)
|
||||
|
||||
**Solution:**
|
||||
1. Fixed all Chat Trigger comparisons
|
||||
2. Added streamResponse validation
|
||||
3. Fixed checkIfStreamingTarget comparison
|
||||
|
||||
**Verified:**
|
||||
- Test with streaming+main outputs → Gets STREAMING_WITH_MAIN_OUTPUT error
|
||||
- Test with streaming to AI Agent → Passes (no error)
|
||||
|
||||
## Commits
|
||||
|
||||
- **91ad084**: Phase 1 TypeScript fixes
|
||||
- **92eb4ef**: Phase 2 critical validation fixes (this commit)
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Remaining Phase 2 (Low Priority)
|
||||
- MEDIUM-02: get_node_essentials examples retrieval
|
||||
|
||||
### Phase 3 (Code Quality)
|
||||
- Standardize validator signatures
|
||||
- Add circular reference validation
|
||||
- Improve URL validation
|
||||
- Extract magic numbers
|
||||
|
||||
### Phase 4 (Tests & Docs)
|
||||
- Add edge case tests
|
||||
- Update README and CHANGELOG
|
||||
- Bump version to 2.17.0
|
||||
|
||||
## Performance Impact
|
||||
|
||||
**Before:** 0 AI validations running (0% functionality)
|
||||
**After:** 100% AI validations working correctly
|
||||
|
||||
**Test improvement:**
|
||||
- Phase 0: 24/64 tests passing (37.5%)
|
||||
- Phase 1: 33/64 tests passing (51.6%) - +37.5%
|
||||
- Phase 2: ~40/64 tests passing (62.5%) - +21%
|
||||
- **Total improvement: +67% from baseline**
|
||||
@@ -1,484 +0,0 @@
|
||||
# Phase 2 Validation - Test Scenarios
|
||||
|
||||
## Quick Verification Tests
|
||||
|
||||
After reloading the MCP server, run these tests to verify all Phase 2 fixes work correctly.
|
||||
|
||||
---
|
||||
|
||||
## Test 1: Missing Language Model Detection ✅
|
||||
|
||||
**Issue**: HIGH-01 - AI Agent without language model wasn't validated
|
||||
|
||||
**Test Workflow**:
|
||||
```json
|
||||
{
|
||||
"name": "Test Missing LM",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "agent1",
|
||||
"name": "AI Agent",
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"position": [500, 300],
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "You are a helpful assistant"
|
||||
},
|
||||
"typeVersion": 1.7
|
||||
}
|
||||
],
|
||||
"connections": {}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Result**:
|
||||
```
|
||||
valid: false
|
||||
errors: [
|
||||
{
|
||||
type: "error",
|
||||
message: "AI Agent \"AI Agent\" requires an ai_languageModel connection...",
|
||||
code: "MISSING_LANGUAGE_MODEL"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Verify**: Error is returned with code `MISSING_LANGUAGE_MODEL`
|
||||
|
||||
---
|
||||
|
||||
## Test 2: AI Tool Connection Detection ✅
|
||||
|
||||
**Issue**: HIGH-04 - False "no tools connected" warning when tools ARE connected
|
||||
|
||||
**Test Workflow**:
|
||||
```json
|
||||
{
|
||||
"name": "Test Tool Detection",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "openai1",
|
||||
"name": "OpenAI Chat Model",
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||
"position": [200, 300],
|
||||
"parameters": {
|
||||
"modelName": "gpt-4"
|
||||
},
|
||||
"typeVersion": 1
|
||||
},
|
||||
{
|
||||
"id": "tool1",
|
||||
"name": "HTTP Request Tool",
|
||||
"type": "@n8n/n8n-nodes-langchain.toolHttpRequest",
|
||||
"position": [200, 400],
|
||||
"parameters": {
|
||||
"toolDescription": "Calls a weather API",
|
||||
"url": "https://api.weather.com"
|
||||
},
|
||||
"typeVersion": 1.1
|
||||
},
|
||||
{
|
||||
"id": "agent1",
|
||||
"name": "AI Agent",
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"position": [500, 300],
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "You are a helpful assistant"
|
||||
},
|
||||
"typeVersion": 1.7
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"OpenAI Chat Model": {
|
||||
"ai_languageModel": [[{
|
||||
"node": "AI Agent",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}]]
|
||||
},
|
||||
"HTTP Request Tool": {
|
||||
"ai_tool": [[{
|
||||
"node": "AI Agent",
|
||||
"type": "ai_tool",
|
||||
"index": 0
|
||||
}]]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Result**:
|
||||
```
|
||||
valid: true (or only warnings, NO error about missing tools)
|
||||
warnings: [] (should NOT contain "no ai_tool connections")
|
||||
```
|
||||
|
||||
**Verify**: No false warning about missing tools
|
||||
|
||||
---
|
||||
|
||||
## Test 3A: Streaming Mode - Chat Trigger ✅
|
||||
|
||||
**Issue**: HIGH-08 - Streaming mode with main output wasn't validated
|
||||
|
||||
**Test Workflow**:
|
||||
```json
|
||||
{
|
||||
"name": "Test Streaming Chat Trigger",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "trigger1",
|
||||
"name": "Chat Trigger",
|
||||
"type": "@n8n/n8n-nodes-langchain.chatTrigger",
|
||||
"position": [100, 300],
|
||||
"parameters": {
|
||||
"options": {
|
||||
"responseMode": "streaming"
|
||||
}
|
||||
},
|
||||
"typeVersion": 1
|
||||
},
|
||||
{
|
||||
"id": "openai1",
|
||||
"name": "OpenAI Chat Model",
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||
"position": [300, 200],
|
||||
"parameters": {
|
||||
"modelName": "gpt-4"
|
||||
},
|
||||
"typeVersion": 1
|
||||
},
|
||||
{
|
||||
"id": "agent1",
|
||||
"name": "AI Agent",
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"position": [500, 300],
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "You are a helpful assistant"
|
||||
},
|
||||
"typeVersion": 1.7
|
||||
},
|
||||
{
|
||||
"id": "response1",
|
||||
"name": "Response Node",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"position": [700, 300],
|
||||
"parameters": {},
|
||||
"typeVersion": 1
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Chat Trigger": {
|
||||
"main": [[{
|
||||
"node": "AI Agent",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}]]
|
||||
},
|
||||
"OpenAI Chat Model": {
|
||||
"ai_languageModel": [[{
|
||||
"node": "AI Agent",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}]]
|
||||
},
|
||||
"AI Agent": {
|
||||
"main": [[{
|
||||
"node": "Response Node",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}]]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Result**:
|
||||
```
|
||||
valid: false
|
||||
errors: [
|
||||
{
|
||||
type: "error",
|
||||
message: "AI Agent \"AI Agent\" is in streaming mode... but has outgoing main connections...",
|
||||
code: "STREAMING_WITH_MAIN_OUTPUT" or "STREAMING_AGENT_HAS_OUTPUT"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Verify**: Error about streaming with main output
|
||||
|
||||
---
|
||||
|
||||
## Test 3B: Streaming Mode - AI Agent Own Setting ✅
|
||||
|
||||
**Issue**: HIGH-08 - Streaming mode validation incomplete (only checked Chat Trigger)
|
||||
|
||||
**Test Workflow**:
|
||||
```json
|
||||
{
|
||||
"name": "Test Streaming AI Agent",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "openai1",
|
||||
"name": "OpenAI Chat Model",
|
||||
"type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
|
||||
"position": [200, 300],
|
||||
"parameters": {
|
||||
"modelName": "gpt-4"
|
||||
},
|
||||
"typeVersion": 1
|
||||
},
|
||||
{
|
||||
"id": "agent1",
|
||||
"name": "AI Agent",
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"position": [500, 300],
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "You are a helpful assistant",
|
||||
"options": {
|
||||
"streamResponse": true
|
||||
}
|
||||
},
|
||||
"typeVersion": 1.7
|
||||
},
|
||||
{
|
||||
"id": "response1",
|
||||
"name": "Response Node",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"position": [700, 300],
|
||||
"parameters": {},
|
||||
"typeVersion": 1
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"OpenAI Chat Model": {
|
||||
"ai_languageModel": [[{
|
||||
"node": "AI Agent",
|
||||
"type": "ai_languageModel",
|
||||
"index": 0
|
||||
}]]
|
||||
},
|
||||
"AI Agent": {
|
||||
"main": [[{
|
||||
"node": "Response Node",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}]]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Result**:
|
||||
```
|
||||
valid: false
|
||||
errors: [
|
||||
{
|
||||
type: "error",
|
||||
message: "AI Agent \"AI Agent\" is in streaming mode (has streamResponse=true in options)...",
|
||||
code: "STREAMING_WITH_MAIN_OUTPUT"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Verify**: Detects streaming from AI Agent's own setting, not just Chat Trigger
|
||||
|
||||
---
|
||||
|
||||
## Test 4: get_node_essentials Examples ✅
|
||||
|
||||
**Issue**: MEDIUM-02 - Examples always returned empty array
|
||||
|
||||
**MCP Call**:
|
||||
```javascript
|
||||
get_node_essentials({
|
||||
nodeType: "@n8n/n8n-nodes-langchain.agent",
|
||||
includeExamples: true
|
||||
})
|
||||
```
|
||||
|
||||
**Expected Result**:
|
||||
```json
|
||||
{
|
||||
"nodeType": "nodes-langchain.agent",
|
||||
"workflowNodeType": "@n8n/n8n-nodes-langchain.agent",
|
||||
"displayName": "AI Agent",
|
||||
"examples": [
|
||||
{
|
||||
"configuration": { /* actual config */ },
|
||||
"source": {
|
||||
"template": "...",
|
||||
"views": 99999,
|
||||
"complexity": "medium"
|
||||
},
|
||||
"useCases": ["..."],
|
||||
"metadata": {
|
||||
"hasCredentials": false,
|
||||
"hasExpressions": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"examplesCount": 3
|
||||
}
|
||||
```
|
||||
|
||||
**Verify**:
|
||||
- `examples` is an array with length > 0
|
||||
- Each example has `configuration`, `source`, `useCases`, `metadata`
|
||||
- `examplesCount` matches examples.length
|
||||
|
||||
**Note**: Requires templates to be fetched first:
|
||||
```bash
|
||||
npm run fetch:templates
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test 5: Integration - Multiple Errors ✅
|
||||
|
||||
**Test Workflow**: Combine multiple errors
|
||||
```json
|
||||
{
|
||||
"name": "Test Multiple Errors",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "trigger1",
|
||||
"name": "Chat Trigger",
|
||||
"type": "@n8n/n8n-nodes-langchain.chatTrigger",
|
||||
"position": [100, 300],
|
||||
"parameters": {
|
||||
"options": {
|
||||
"responseMode": "streaming"
|
||||
}
|
||||
},
|
||||
"typeVersion": 1
|
||||
},
|
||||
{
|
||||
"id": "agent1",
|
||||
"name": "AI Agent",
|
||||
"type": "@n8n/n8n-nodes-langchain.agent",
|
||||
"position": [500, 300],
|
||||
"parameters": {
|
||||
"promptType": "define",
|
||||
"text": "You are a helpful assistant"
|
||||
},
|
||||
"typeVersion": 1.7
|
||||
},
|
||||
{
|
||||
"id": "response1",
|
||||
"name": "Response Node",
|
||||
"type": "n8n-nodes-base.respondToWebhook",
|
||||
"position": [700, 300],
|
||||
"parameters": {},
|
||||
"typeVersion": 1
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Chat Trigger": {
|
||||
"main": [[{
|
||||
"node": "AI Agent",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}]]
|
||||
},
|
||||
"AI Agent": {
|
||||
"main": [[{
|
||||
"node": "Response Node",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}]]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Expected Result**:
|
||||
```
|
||||
valid: false
|
||||
errors: [
|
||||
{
|
||||
type: "error",
|
||||
code: "MISSING_LANGUAGE_MODEL",
|
||||
message: "AI Agent \"AI Agent\" requires an ai_languageModel connection..."
|
||||
},
|
||||
{
|
||||
type: "error",
|
||||
code: "STREAMING_WITH_MAIN_OUTPUT" or "STREAMING_AGENT_HAS_OUTPUT",
|
||||
message: "AI Agent \"AI Agent\" is in streaming mode... but has outgoing main connections..."
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Verify**: Both validation errors are detected and reported
|
||||
|
||||
---
|
||||
|
||||
## How to Run Tests
|
||||
|
||||
### Option 1: Using MCP Tools (Recommended)
|
||||
|
||||
After reloading MCP server, use the validation tools:
|
||||
|
||||
```javascript
|
||||
// For workflow validation
|
||||
validate_workflow({
|
||||
workflow: { /* paste test workflow JSON */ },
|
||||
profile: "ai-friendly"
|
||||
})
|
||||
|
||||
// For examples
|
||||
get_node_essentials({
|
||||
nodeType: "@n8n/n8n-nodes-langchain.agent",
|
||||
includeExamples: true
|
||||
})
|
||||
```
|
||||
|
||||
### Option 2: Using Debug Script
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npx tsx scripts/test-ai-validation-debug.ts
|
||||
```
|
||||
|
||||
### Option 3: Using n8n-mcp-tester Agent
|
||||
|
||||
Ask the n8n-mcp-tester agent to run specific test scenarios from this document.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ All 5 test scenarios pass
|
||||
✅ Error codes match expected values
|
||||
✅ Error messages are clear and actionable
|
||||
✅ No false positives or false negatives
|
||||
✅ Examples retrieval works for AI nodes
|
||||
|
||||
---
|
||||
|
||||
## Fixes Applied
|
||||
|
||||
1. **Node Type Normalization** (21 locations)
|
||||
- Changed all comparisons from FULL form to SHORT form
|
||||
- Affects: ai-node-validator.ts, ai-tool-validators.ts
|
||||
|
||||
2. **Streaming Validation Enhancement**
|
||||
- Added check for AI Agent's own streamResponse setting
|
||||
- Previously only checked Chat Trigger streaming
|
||||
|
||||
3. **Examples Retrieval Consistency**
|
||||
- Use result.workflowNodeType instead of reconstructing
|
||||
- Matches search_nodes behavior
|
||||
|
||||
---
|
||||
|
||||
## Commits
|
||||
|
||||
- `92eb4ef`: Critical validation fixes (node type normalization)
|
||||
- `81dfbbb`: Examples retrieval fix (workflowNodeType consistency)
|
||||
- `3ba3f10`: Phase 2 completion documentation
|
||||
|
||||
Total: 3 commits, ~250 lines changed
|
||||
@@ -5,7 +5,7 @@
|
||||
[](https://www.npmjs.com/package/n8n-mcp)
|
||||
[](https://codecov.io/gh/czlonkowski/n8n-mcp)
|
||||
[](https://github.com/czlonkowski/n8n-mcp/actions)
|
||||
[](https://github.com/n8n-io/n8n)
|
||||
[](https://github.com/n8n-io/n8n)
|
||||
[](https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp)
|
||||
[](https://railway.com/deploy/n8n-mcp?referralCode=n8n-mcp)
|
||||
|
||||
|
||||
BIN
data/nodes.db
BIN
data/nodes.db
Binary file not shown.
@@ -1,62 +0,0 @@
|
||||
# PR #104 Test Suite Improvements Summary
|
||||
|
||||
## Overview
|
||||
Based on comprehensive review feedback from PR #104, we've significantly improved the test suite quality, organization, and coverage.
|
||||
|
||||
## Test Results
|
||||
- **Before:** 78 failing tests
|
||||
- **After:** 0 failing tests (1,356 passed, 19 skipped)
|
||||
- **Coverage:** 85.34% statements, 85.3% branches
|
||||
|
||||
## Key Improvements
|
||||
|
||||
### 1. Fixed All Test Failures
|
||||
- Fixed logger test spy issues by properly handling DEBUG environment variable
|
||||
- Fixed MSW configuration test by restoring environment variables
|
||||
- Fixed workflow validator tests by adding proper node connections
|
||||
- Fixed mock setup issues in edge case tests
|
||||
|
||||
### 2. Improved Test Organization
|
||||
- Split large config-validator.test.ts (1,075 lines) into 4 focused files:
|
||||
- config-validator-basic.test.ts
|
||||
- config-validator-node-specific.test.ts
|
||||
- config-validator-security.test.ts
|
||||
- config-validator-edge-cases.test.ts
|
||||
|
||||
### 3. Enhanced Test Coverage
|
||||
- Added comprehensive edge case tests for all major validators
|
||||
- Added null/undefined handling tests
|
||||
- Added boundary value tests
|
||||
- Added performance tests with CI-aware timeouts
|
||||
- Added security validation tests
|
||||
|
||||
### 4. Improved Test Quality
|
||||
- Fixed test naming conventions (100% compliance with "should X when Y" pattern)
|
||||
- Added JSDoc comments to test utilities and factories
|
||||
- Created comprehensive test documentation (tests/README.md)
|
||||
- Improved test isolation to prevent cross-test pollution
|
||||
|
||||
### 5. New Features
|
||||
- Implemented validateBatch method for ConfigValidator
|
||||
- Added test factories for better test data management
|
||||
- Created test utilities for common scenarios
|
||||
|
||||
## Files Modified
|
||||
- 7 existing test files fixed
|
||||
- 8 new test files created
|
||||
- 1 source file enhanced (ConfigValidator)
|
||||
- 4 debug files removed before commit
|
||||
|
||||
## Skipped Tests
|
||||
19 tests remain skipped with documented reasons:
|
||||
- FTS5 search sync test (database corruption in CI)
|
||||
- Template clearing (not implemented)
|
||||
- Mock API configuration tests
|
||||
- Duplicate edge case tests with mocking issues (working versions exist)
|
||||
|
||||
## Next Steps
|
||||
The only remaining task from the improvement plan is:
|
||||
- Add performance regression tests and boundaries (low priority, future sprint)
|
||||
|
||||
## Conclusion
|
||||
The test suite is now robust, well-organized, and provides excellent coverage. All critical issues have been resolved, and the codebase is ready for merge.
|
||||
@@ -1,314 +0,0 @@
|
||||
# Template Metadata Generation
|
||||
|
||||
This document describes the template metadata generation system introduced in n8n-MCP v2.10.0, which uses OpenAI's batch API to automatically analyze and categorize workflow templates.
|
||||
|
||||
## Overview
|
||||
|
||||
The template metadata system analyzes n8n workflow templates to extract structured information about their purpose, complexity, requirements, and target audience. This enables intelligent template discovery through advanced filtering capabilities.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
1. **MetadataGenerator** (`src/templates/metadata-generator.ts`)
|
||||
- Interfaces with OpenAI API
|
||||
- Generates structured metadata using JSON schemas
|
||||
- Provides fallback defaults for error cases
|
||||
|
||||
2. **BatchProcessor** (`src/templates/batch-processor.ts`)
|
||||
- Manages OpenAI batch API operations
|
||||
- Handles parallel batch submission
|
||||
- Monitors batch status and retrieves results
|
||||
|
||||
3. **Template Repository** (`src/templates/template-repository.ts`)
|
||||
- Stores metadata in SQLite database
|
||||
- Provides advanced search capabilities
|
||||
- Supports JSON extraction queries
|
||||
|
||||
## Metadata Schema
|
||||
|
||||
Each template's metadata contains:
|
||||
|
||||
```typescript
|
||||
{
|
||||
categories: string[] // Max 5 categories (e.g., "automation", "integration")
|
||||
complexity: "simple" | "medium" | "complex"
|
||||
use_cases: string[] // Max 5 primary use cases
|
||||
estimated_setup_minutes: number // 5-480 minutes
|
||||
required_services: string[] // External services needed
|
||||
key_features: string[] // Max 5 main capabilities
|
||||
target_audience: string[] // Max 3 target user types
|
||||
}
|
||||
```
|
||||
|
||||
## Generation Process
|
||||
|
||||
### 1. Initial Setup
|
||||
|
||||
```bash
|
||||
# Set OpenAI API key in .env
|
||||
OPENAI_API_KEY=your-api-key-here
|
||||
```
|
||||
|
||||
### 2. Generate Metadata for Existing Templates
|
||||
|
||||
```bash
|
||||
# Generate metadata only (no template fetching)
|
||||
npm run fetch:templates -- --metadata-only
|
||||
|
||||
# Generate metadata during update
|
||||
npm run fetch:templates -- --mode=update --generate-metadata
|
||||
```
|
||||
|
||||
### 3. Batch Processing
|
||||
|
||||
The system uses OpenAI's batch API for cost-effective processing:
|
||||
|
||||
- **50% cost reduction** compared to synchronous API calls
|
||||
- **24-hour processing window** for batch completion
|
||||
- **Parallel batch submission** for faster processing
|
||||
- **Automatic retry** for failed items
|
||||
|
||||
### Configuration Options
|
||||
|
||||
Environment variables:
|
||||
- `OPENAI_API_KEY`: Required for metadata generation
|
||||
- `OPENAI_MODEL`: Model to use (default: "gpt-4o-mini")
|
||||
- `OPENAI_BATCH_SIZE`: Templates per batch (default: 100, max: 500)
|
||||
- `METADATA_LIMIT`: Limit templates to process (for testing)
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. Template Analysis
|
||||
|
||||
For each template, the generator analyzes:
|
||||
- Template name and description
|
||||
- Node types and their frequency
|
||||
- Workflow structure and connections
|
||||
- Overall complexity
|
||||
|
||||
### 2. Node Summarization
|
||||
|
||||
Nodes are grouped into categories:
|
||||
- HTTP/Webhooks
|
||||
- Database operations
|
||||
- Communication (Slack, Email)
|
||||
- AI/ML operations
|
||||
- Spreadsheets
|
||||
- Service-specific nodes
|
||||
|
||||
### 3. Metadata Generation
|
||||
|
||||
The AI model receives:
|
||||
```
|
||||
Template: [name]
|
||||
Description: [description]
|
||||
Nodes Used (X): [summarized node list]
|
||||
Workflow has X nodes with Y connections
|
||||
```
|
||||
|
||||
And generates structured metadata following the JSON schema.
|
||||
|
||||
### 4. Storage and Indexing
|
||||
|
||||
Metadata is stored as JSON in SQLite and indexed for fast querying:
|
||||
|
||||
```sql
|
||||
-- Example query for simple automation templates
|
||||
SELECT * FROM templates
|
||||
WHERE json_extract(metadata, '$.complexity') = 'simple'
|
||||
AND json_extract(metadata, '$.categories') LIKE '%automation%'
|
||||
```
|
||||
|
||||
## MCP Tool Integration
|
||||
|
||||
### search_templates_by_metadata
|
||||
|
||||
Advanced filtering tool with multiple parameters:
|
||||
|
||||
```typescript
|
||||
search_templates_by_metadata({
|
||||
category: "automation", // Filter by category
|
||||
complexity: "simple", // Skill level
|
||||
maxSetupMinutes: 30, // Time constraint
|
||||
targetAudience: "marketers", // Role-based
|
||||
requiredService: "slack" // Service dependency
|
||||
})
|
||||
```
|
||||
|
||||
### list_templates
|
||||
|
||||
Enhanced to include metadata:
|
||||
|
||||
```typescript
|
||||
list_templates({
|
||||
includeMetadata: true, // Include full metadata
|
||||
limit: 20,
|
||||
offset: 0
|
||||
})
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Finding Beginner-Friendly Templates
|
||||
|
||||
```typescript
|
||||
const templates = await search_templates_by_metadata({
|
||||
complexity: "simple",
|
||||
maxSetupMinutes: 15
|
||||
});
|
||||
```
|
||||
|
||||
### Role-Specific Templates
|
||||
|
||||
```typescript
|
||||
const marketingTemplates = await search_templates_by_metadata({
|
||||
targetAudience: "marketers",
|
||||
category: "communication"
|
||||
});
|
||||
```
|
||||
|
||||
### Service Integration Templates
|
||||
|
||||
```typescript
|
||||
const openaiTemplates = await search_templates_by_metadata({
|
||||
requiredService: "openai",
|
||||
complexity: "medium"
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
- **Coverage**: 97.5% of templates have metadata (2,534/2,598)
|
||||
- **Generation Time**: ~2-4 hours for full database (using batch API)
|
||||
- **Query Performance**: <100ms for metadata searches
|
||||
- **Storage Overhead**: ~2MB additional database size
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Batch Processing Stuck**
|
||||
- Check batch status: The API provides status updates
|
||||
- Batches auto-expire after 24 hours
|
||||
- Monitor using the batch ID in logs
|
||||
|
||||
2. **Missing Metadata**
|
||||
- ~2.5% of templates may fail metadata generation
|
||||
- Fallback defaults are provided
|
||||
- Can regenerate with `--metadata-only` flag
|
||||
|
||||
3. **API Rate Limits**
|
||||
- Batch API has generous limits (50,000 requests/batch)
|
||||
- Cost is 50% of synchronous API
|
||||
- Processing happens within 24-hour window
|
||||
|
||||
### Monitoring Batch Status
|
||||
|
||||
```bash
|
||||
# Check current batch status (if logged)
|
||||
curl https://api.openai.com/v1/batches/[batch-id] \
|
||||
-H "Authorization: Bearer $OPENAI_API_KEY"
|
||||
```
|
||||
|
||||
## Cost Analysis
|
||||
|
||||
### Batch API Pricing (gpt-4o-mini)
|
||||
|
||||
- Input: $0.075 per 1M tokens (50% of standard)
|
||||
- Output: $0.30 per 1M tokens (50% of standard)
|
||||
- Average template: ~300 input tokens, ~200 output tokens
|
||||
- Total cost for 2,500 templates: ~$0.50
|
||||
|
||||
### Comparison with Synchronous API
|
||||
|
||||
- Synchronous cost: ~$1.00 for same volume
|
||||
- Time saved: Parallel processing vs sequential
|
||||
- Reliability: Automatic retries included
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Improvements
|
||||
|
||||
1. **Incremental Updates**
|
||||
- Only generate metadata for new templates
|
||||
- Track metadata version for updates
|
||||
|
||||
2. **Enhanced Analysis**
|
||||
- Workflow complexity scoring
|
||||
- Dependency graph analysis
|
||||
- Performance impact estimates
|
||||
|
||||
3. **User Feedback Loop**
|
||||
- Collect accuracy feedback
|
||||
- Refine categorization over time
|
||||
- Community-driven corrections
|
||||
|
||||
4. **Alternative Models**
|
||||
- Support for local LLMs
|
||||
- Claude API integration
|
||||
- Configurable model selection
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Database Schema
|
||||
|
||||
```sql
|
||||
-- Metadata stored as JSON column
|
||||
ALTER TABLE templates ADD COLUMN metadata TEXT;
|
||||
|
||||
-- Indexes for common queries
|
||||
CREATE INDEX idx_templates_complexity ON templates(
|
||||
json_extract(metadata, '$.complexity')
|
||||
);
|
||||
CREATE INDEX idx_templates_setup_time ON templates(
|
||||
json_extract(metadata, '$.estimated_setup_minutes')
|
||||
);
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
The system provides robust error handling:
|
||||
|
||||
1. **API Failures**: Fallback to default metadata
|
||||
2. **Parsing Errors**: Logged with template ID
|
||||
3. **Batch Failures**: Individual item retry
|
||||
4. **Validation Errors**: Zod schema enforcement
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Regenerating Metadata
|
||||
|
||||
```bash
|
||||
# Full regeneration (caution: costs ~$0.50)
|
||||
npm run fetch:templates -- --mode=rebuild --generate-metadata
|
||||
|
||||
# Partial regeneration (templates without metadata)
|
||||
npm run fetch:templates -- --metadata-only
|
||||
```
|
||||
|
||||
### Database Backup
|
||||
|
||||
```bash
|
||||
# Backup before regeneration
|
||||
cp data/nodes.db data/nodes.db.backup
|
||||
|
||||
# Restore if needed
|
||||
cp data/nodes.db.backup data/nodes.db
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **API Key Management**
|
||||
- Store in `.env` file (gitignored)
|
||||
- Never commit API keys
|
||||
- Use environment variables in CI/CD
|
||||
|
||||
2. **Data Privacy**
|
||||
- Only template structure is sent to API
|
||||
- No user data or credentials included
|
||||
- Processing happens in OpenAI's secure environment
|
||||
|
||||
## Conclusion
|
||||
|
||||
The template metadata system transforms template discovery from simple text search to intelligent, multi-dimensional filtering. By leveraging OpenAI's batch API, we achieve cost-effective, scalable metadata generation that significantly improves the user experience for finding relevant workflow templates.
|
||||
@@ -1,162 +0,0 @@
|
||||
# Issue #90: "propertyValues[itemName] is not iterable" Error - Research Findings
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The error "propertyValues[itemName] is not iterable" occurs when AI agents create workflows with incorrect data structures for n8n nodes that use `fixedCollection` properties. This primarily affects Switch Node v2, If Node, and Filter Node. The error prevents workflows from loading in the n8n UI, resulting in empty canvases.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### 1. Data Structure Mismatch
|
||||
|
||||
The error occurs when n8n's validation engine expects an iterable array but encounters a non-iterable object. This happens with nodes using `fixedCollection` type properties.
|
||||
|
||||
**Incorrect Structure (causes error):**
|
||||
```json
|
||||
{
|
||||
"rules": {
|
||||
"conditions": {
|
||||
"values": [
|
||||
{
|
||||
"value1": "={{$json.status}}",
|
||||
"operation": "equals",
|
||||
"value2": "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Correct Structure:**
|
||||
```json
|
||||
{
|
||||
"rules": {
|
||||
"conditions": [
|
||||
{
|
||||
"value1": "={{$json.status}}",
|
||||
"operation": "equals",
|
||||
"value2": "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Affected Nodes
|
||||
|
||||
Based on the research and issue comments, the following nodes are affected:
|
||||
|
||||
1. **Switch Node v2** (`n8n-nodes-base.switch` with typeVersion: 2)
|
||||
- Uses `rules` parameter with `conditions` fixedCollection
|
||||
- v3 doesn't have this issue due to restructured schema
|
||||
|
||||
2. **If Node** (`n8n-nodes-base.if` with typeVersion: 1)
|
||||
- Uses `conditions` parameter with nested conditions array
|
||||
- Similar structure to Switch v2
|
||||
|
||||
3. **Filter Node** (`n8n-nodes-base.filter`)
|
||||
- Uses `conditions` parameter
|
||||
- Same fixedCollection pattern
|
||||
|
||||
### 3. Why AI Agents Create Incorrect Structures
|
||||
|
||||
1. **Training Data Issues**: AI models may have been trained on outdated or incorrect n8n workflow examples
|
||||
2. **Nested Object Inference**: AI tends to create unnecessarily nested structures when it sees collection-type parameters
|
||||
3. **Legacy Format Confusion**: Mixing v2 and v3 Switch node formats
|
||||
4. **Schema Misinterpretation**: The term "fixedCollection" may lead AI to create object wrappers
|
||||
|
||||
## Current Impact
|
||||
|
||||
From issue #90 comments:
|
||||
- Multiple users experiencing the issue
|
||||
- Workflows fail to load completely (empty canvas)
|
||||
- Users resort to using Switch Node v3 or direct API calls
|
||||
- The issue appears in "most MCPs" according to user feedback
|
||||
|
||||
## Recommended Actions
|
||||
|
||||
### 1. Immediate Validation Enhancement
|
||||
|
||||
Add specific validation for fixedCollection properties in the workflow validator:
|
||||
|
||||
```typescript
|
||||
// In workflow-validator.ts or enhanced-config-validator.ts
|
||||
function validateFixedCollectionParameters(node, result) {
|
||||
const problematicNodes = {
|
||||
'n8n-nodes-base.switch': { version: 2, fields: ['rules'] },
|
||||
'n8n-nodes-base.if': { version: 1, fields: ['conditions'] },
|
||||
'n8n-nodes-base.filter': { version: 1, fields: ['conditions'] }
|
||||
};
|
||||
|
||||
const nodeConfig = problematicNodes[node.type];
|
||||
if (nodeConfig && node.typeVersion === nodeConfig.version) {
|
||||
// Validate structure
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Enhanced MCP Tool Validation
|
||||
|
||||
Update the validation tools to detect and prevent this specific error pattern:
|
||||
|
||||
1. **In `validate_node_operation` tool**: Add checks for fixedCollection structures
|
||||
2. **In `validate_workflow` tool**: Include specific validation for Switch/If nodes
|
||||
3. **In `n8n_create_workflow` tool**: Pre-validate parameters before submission
|
||||
|
||||
### 3. AI-Friendly Examples
|
||||
|
||||
Update workflow examples to show correct structures:
|
||||
|
||||
```typescript
|
||||
// In workflow-examples.ts
|
||||
export const SWITCH_NODE_EXAMPLE = {
|
||||
name: "Switch",
|
||||
type: "n8n-nodes-base.switch",
|
||||
typeVersion: 3, // Prefer v3 over v2
|
||||
parameters: {
|
||||
// Correct v3 structure
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 4. Migration Strategy
|
||||
|
||||
For existing workflows with Switch v2:
|
||||
1. Detect Switch v2 nodes in validation
|
||||
2. Suggest migration to v3
|
||||
3. Provide automatic conversion utility
|
||||
|
||||
### 5. Documentation Updates
|
||||
|
||||
1. Add warnings about fixedCollection structures in tool documentation
|
||||
2. Include specific examples of correct vs incorrect structures
|
||||
3. Document the Switch v2 to v3 migration path
|
||||
|
||||
## Proposed Implementation Priority
|
||||
|
||||
1. **High Priority**: Add validation to prevent creation of invalid structures
|
||||
2. **High Priority**: Update existing validation tools to catch this error
|
||||
3. **Medium Priority**: Add auto-fix capabilities to correct structures
|
||||
4. **Medium Priority**: Update examples and documentation
|
||||
5. **Low Priority**: Create migration utilities for v2 to v3
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. Create test cases for each affected node type
|
||||
2. Test both correct and incorrect structures
|
||||
3. Verify validation catches all variants of the error
|
||||
4. Test auto-fix suggestions work correctly
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- Zero instances of "propertyValues[itemName] is not iterable" in newly created workflows
|
||||
- Clear error messages that guide users to correct structures
|
||||
- Successful validation of all Switch/If node configurations before workflow creation
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Implement validation enhancements in the workflow validator
|
||||
2. Update MCP tools to include these validations
|
||||
3. Add comprehensive tests
|
||||
4. Update documentation with clear examples
|
||||
5. Consider adding a migration tool for existing workflows
|
||||
@@ -1,712 +0,0 @@
|
||||
# MCP Tools Documentation for LLMs
|
||||
|
||||
This document provides comprehensive documentation for the most commonly used MCP tools in the n8n-mcp server. Each tool includes parameters, return formats, examples, and best practices.
|
||||
|
||||
## Table of Contents
|
||||
1. [search_nodes](#search_nodes)
|
||||
2. [get_node_essentials](#get_node_essentials)
|
||||
3. [list_nodes](#list_nodes)
|
||||
4. [validate_node_minimal](#validate_node_minimal)
|
||||
5. [validate_node_operation](#validate_node_operation)
|
||||
6. [get_node_for_task](#get_node_for_task)
|
||||
7. [n8n_create_workflow](#n8n_create_workflow)
|
||||
8. [n8n_update_partial_workflow](#n8n_update_partial_workflow)
|
||||
|
||||
---
|
||||
|
||||
## search_nodes
|
||||
|
||||
**Brief Description**: Search for n8n nodes by keywords in names and descriptions.
|
||||
|
||||
### Parameters
|
||||
- `query` (string, required): Search term - single word recommended for best results
|
||||
- `limit` (number, optional): Maximum results to return (default: 20)
|
||||
|
||||
### Return Format
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"nodeType": "nodes-base.slack",
|
||||
"displayName": "Slack",
|
||||
"description": "Send messages to Slack channels"
|
||||
}
|
||||
],
|
||||
"totalFound": 5
|
||||
}
|
||||
```
|
||||
|
||||
### Common Use Cases
|
||||
1. **Finding integration nodes**: `search_nodes("slack")` to find Slack integration
|
||||
2. **Finding HTTP nodes**: `search_nodes("http")` for HTTP/webhook nodes
|
||||
3. **Finding database nodes**: `search_nodes("postgres")` for PostgreSQL nodes
|
||||
|
||||
### Examples
|
||||
```json
|
||||
// Search for Slack-related nodes
|
||||
{
|
||||
"query": "slack",
|
||||
"limit": 10
|
||||
}
|
||||
|
||||
// Search for webhook nodes
|
||||
{
|
||||
"query": "webhook",
|
||||
"limit": 20
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Notes
|
||||
- Fast operation (cached results)
|
||||
- Single-word queries are more precise
|
||||
- Returns results with OR logic (any word matches)
|
||||
|
||||
### Best Practices
|
||||
- Use single words for precise results: "slack" not "send slack message"
|
||||
- Try shorter terms if no results: "sheet" instead of "spreadsheet"
|
||||
- Search is case-insensitive
|
||||
- Common searches: "http", "webhook", "email", "database", "slack"
|
||||
|
||||
### Common Pitfalls
|
||||
- Multi-word searches return too many results (OR logic)
|
||||
- Searching for exact phrases doesn't work
|
||||
- Node types aren't searchable here (use exact type with get_node_info)
|
||||
|
||||
### Related Tools
|
||||
- `list_nodes` - Browse nodes by category
|
||||
- `get_node_essentials` - Get node configuration after finding it
|
||||
- `list_ai_tools` - Find AI-capable nodes specifically
|
||||
|
||||
---
|
||||
|
||||
## get_node_essentials
|
||||
|
||||
**Brief Description**: Get only the 10-20 most important properties for a node with working examples.
|
||||
|
||||
### Parameters
|
||||
- `nodeType` (string, required): Full node type with prefix (e.g., "nodes-base.httpRequest")
|
||||
|
||||
### Return Format
|
||||
```json
|
||||
{
|
||||
"nodeType": "nodes-base.httpRequest",
|
||||
"displayName": "HTTP Request",
|
||||
"essentialProperties": [
|
||||
{
|
||||
"name": "method",
|
||||
"type": "options",
|
||||
"default": "GET",
|
||||
"options": ["GET", "POST", "PUT", "DELETE"],
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "url",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"placeholder": "https://api.example.com/endpoint"
|
||||
}
|
||||
],
|
||||
"examples": [
|
||||
{
|
||||
"name": "Simple GET Request",
|
||||
"configuration": {
|
||||
"method": "GET",
|
||||
"url": "https://api.example.com/users"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tips": [
|
||||
"Use expressions like {{$json.url}} to make URLs dynamic",
|
||||
"Enable 'Split Into Items' for array responses"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Common Use Cases
|
||||
1. **Quick node configuration**: Get just what you need without parsing 100KB+ of data
|
||||
2. **Learning node basics**: Understand essential properties with examples
|
||||
3. **Building workflows efficiently**: 95% smaller responses than get_node_info
|
||||
|
||||
### Examples
|
||||
```json
|
||||
// Get essentials for HTTP Request node
|
||||
{
|
||||
"nodeType": "nodes-base.httpRequest"
|
||||
}
|
||||
|
||||
// Get essentials for Slack node
|
||||
{
|
||||
"nodeType": "nodes-base.slack"
|
||||
}
|
||||
|
||||
// Get essentials for OpenAI node
|
||||
{
|
||||
"nodeType": "nodes-langchain.openAi"
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Notes
|
||||
- Very fast (<5KB responses vs 100KB+ for full info)
|
||||
- Curated for 20+ common nodes
|
||||
- Automatic fallback for unconfigured nodes
|
||||
|
||||
### Best Practices
|
||||
- Always use this before get_node_info
|
||||
- Node type must include prefix: "nodes-base.slack" not "slack"
|
||||
- Check examples section for working configurations
|
||||
- Use tips section for common patterns
|
||||
|
||||
### Common Pitfalls
|
||||
- Forgetting the prefix in node type
|
||||
- Using wrong package name (n8n-nodes-base vs @n8n/n8n-nodes-langchain)
|
||||
- Case sensitivity in node types
|
||||
|
||||
### Related Tools
|
||||
- `get_node_info` - Full schema when essentials aren't enough
|
||||
- `search_node_properties` - Find specific properties
|
||||
- `get_node_for_task` - Pre-configured for common tasks
|
||||
|
||||
---
|
||||
|
||||
## list_nodes
|
||||
|
||||
**Brief Description**: List available n8n nodes with optional filtering by package, category, or capabilities.
|
||||
|
||||
### Parameters
|
||||
- `package` (string, optional): Filter by exact package name
|
||||
- `category` (string, optional): Filter by category (trigger, transform, output, input)
|
||||
- `developmentStyle` (string, optional): Filter by implementation style
|
||||
- `isAITool` (boolean, optional): Filter for AI-capable nodes
|
||||
- `limit` (number, optional): Maximum results (default: 50, max: 500)
|
||||
|
||||
### Return Format
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"nodeType": "nodes-base.webhook",
|
||||
"displayName": "Webhook",
|
||||
"description": "Receive HTTP requests",
|
||||
"categories": ["trigger"],
|
||||
"version": 2
|
||||
}
|
||||
],
|
||||
"total": 104,
|
||||
"hasMore": false
|
||||
}
|
||||
```
|
||||
|
||||
### Common Use Cases
|
||||
1. **Browse all triggers**: `list_nodes({category: "trigger", limit: 200})`
|
||||
2. **List all nodes**: `list_nodes({limit: 500})`
|
||||
3. **Find AI nodes**: `list_nodes({isAITool: true})`
|
||||
4. **Browse core nodes**: `list_nodes({package: "n8n-nodes-base"})`
|
||||
|
||||
### Examples
|
||||
```json
|
||||
// List all trigger nodes
|
||||
{
|
||||
"category": "trigger",
|
||||
"limit": 200
|
||||
}
|
||||
|
||||
// List all AI-capable nodes
|
||||
{
|
||||
"isAITool": true,
|
||||
"limit": 100
|
||||
}
|
||||
|
||||
// List nodes from core package
|
||||
{
|
||||
"package": "n8n-nodes-base",
|
||||
"limit": 200
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Notes
|
||||
- Fast operation (cached results)
|
||||
- Default limit of 50 may miss nodes - use 200+
|
||||
- Returns metadata only, not full schemas
|
||||
|
||||
### Best Practices
|
||||
- Always set limit to 200+ for complete results
|
||||
- Use exact package names: "n8n-nodes-base" not "@n8n/n8n-nodes-base"
|
||||
- Categories are singular: "trigger" not "triggers"
|
||||
- Common categories: trigger (104), transform, output, input
|
||||
|
||||
### Common Pitfalls
|
||||
- Default limit (50) misses many nodes
|
||||
- Using wrong package name format
|
||||
- Multiple filters may return empty results
|
||||
|
||||
### Related Tools
|
||||
- `search_nodes` - Search by keywords
|
||||
- `list_ai_tools` - Specifically for AI nodes
|
||||
- `get_database_statistics` - Overview of all nodes
|
||||
|
||||
---
|
||||
|
||||
## validate_node_minimal
|
||||
|
||||
**Brief Description**: Quick validation checking only for missing required fields.
|
||||
|
||||
### Parameters
|
||||
- `nodeType` (string, required): Node type to validate (e.g., "nodes-base.slack")
|
||||
- `config` (object, required): Node configuration to check
|
||||
|
||||
### Return Format
|
||||
```json
|
||||
{
|
||||
"valid": false,
|
||||
"missingRequired": ["channel", "messageType"],
|
||||
"message": "Missing 2 required fields"
|
||||
}
|
||||
```
|
||||
|
||||
### Common Use Cases
|
||||
1. **Quick validation**: Check if all required fields are present
|
||||
2. **Pre-flight check**: Validate before creating workflow
|
||||
3. **Minimal overhead**: Fastest validation option
|
||||
|
||||
### Examples
|
||||
```json
|
||||
// Validate Slack message configuration
|
||||
{
|
||||
"nodeType": "nodes-base.slack",
|
||||
"config": {
|
||||
"resource": "message",
|
||||
"operation": "send",
|
||||
"text": "Hello World"
|
||||
// Missing: channel
|
||||
}
|
||||
}
|
||||
|
||||
// Validate HTTP Request
|
||||
{
|
||||
"nodeType": "nodes-base.httpRequest",
|
||||
"config": {
|
||||
"method": "POST"
|
||||
// Missing: url
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Notes
|
||||
- Fastest validation option
|
||||
- No schema loading overhead
|
||||
- Returns only missing fields
|
||||
|
||||
### Best Practices
|
||||
- Use for quick checks during workflow building
|
||||
- Follow up with validate_node_operation for complex nodes
|
||||
- Check operation-specific requirements
|
||||
|
||||
### Common Pitfalls
|
||||
- Doesn't validate field values or types
|
||||
- Doesn't check operation-specific requirements
|
||||
- Won't catch configuration errors beyond missing fields
|
||||
|
||||
### Related Tools
|
||||
- `validate_node_operation` - Comprehensive validation
|
||||
- `validate_workflow` - Full workflow validation
|
||||
|
||||
---
|
||||
|
||||
## validate_node_operation
|
||||
|
||||
**Brief Description**: Comprehensive node configuration validation with operation awareness and helpful error messages.
|
||||
|
||||
### Parameters
|
||||
- `nodeType` (string, required): Node type to validate
|
||||
- `config` (object, required): Complete node configuration including operation fields
|
||||
- `profile` (string, optional): Validation profile (minimal, runtime, ai-friendly, strict)
|
||||
|
||||
### Return Format
|
||||
```json
|
||||
{
|
||||
"valid": false,
|
||||
"errors": [
|
||||
{
|
||||
"field": "channel",
|
||||
"message": "Channel is required to send Slack message",
|
||||
"suggestion": "Add channel: '#general' or '@username'"
|
||||
}
|
||||
],
|
||||
"warnings": [
|
||||
{
|
||||
"field": "unfurl_links",
|
||||
"message": "Consider setting unfurl_links: false for better performance"
|
||||
}
|
||||
],
|
||||
"examples": {
|
||||
"minimal": {
|
||||
"resource": "message",
|
||||
"operation": "send",
|
||||
"channel": "#general",
|
||||
"text": "Hello World"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Common Use Cases
|
||||
1. **Complex node validation**: Slack, Google Sheets, databases
|
||||
2. **Operation-specific checks**: Different rules per operation
|
||||
3. **Getting fix suggestions**: Helpful error messages with solutions
|
||||
|
||||
### Examples
|
||||
```json
|
||||
// Validate Slack configuration
|
||||
{
|
||||
"nodeType": "nodes-base.slack",
|
||||
"config": {
|
||||
"resource": "message",
|
||||
"operation": "send",
|
||||
"text": "Hello team!"
|
||||
},
|
||||
"profile": "ai-friendly"
|
||||
}
|
||||
|
||||
// Validate Google Sheets operation
|
||||
{
|
||||
"nodeType": "nodes-base.googleSheets",
|
||||
"config": {
|
||||
"operation": "append",
|
||||
"sheetId": "1234567890",
|
||||
"range": "Sheet1!A:Z"
|
||||
},
|
||||
"profile": "runtime"
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Notes
|
||||
- Slower than minimal validation
|
||||
- Loads full node schema
|
||||
- Operation-aware validation rules
|
||||
|
||||
### Best Practices
|
||||
- Use "ai-friendly" profile for balanced validation
|
||||
- Check examples in response for working configurations
|
||||
- Follow suggestions to fix errors
|
||||
- Essential for complex nodes (Slack, databases, APIs)
|
||||
|
||||
### Common Pitfalls
|
||||
- Forgetting operation fields (resource, operation, action)
|
||||
- Using wrong profile (too strict or too lenient)
|
||||
- Ignoring warnings that could cause runtime issues
|
||||
|
||||
### Related Tools
|
||||
- `validate_node_minimal` - Quick required field check
|
||||
- `get_property_dependencies` - Understand field relationships
|
||||
- `validate_workflow` - Validate entire workflow
|
||||
|
||||
---
|
||||
|
||||
## get_node_for_task
|
||||
|
||||
**Brief Description**: Get pre-configured node settings for common automation tasks.
|
||||
|
||||
### Parameters
|
||||
- `task` (string, required): Task identifier (e.g., "post_json_request", "receive_webhook")
|
||||
|
||||
### Return Format
|
||||
```json
|
||||
{
|
||||
"task": "post_json_request",
|
||||
"nodeType": "nodes-base.httpRequest",
|
||||
"displayName": "HTTP Request",
|
||||
"configuration": {
|
||||
"method": "POST",
|
||||
"url": "={{ $json.api_endpoint }}",
|
||||
"responseFormat": "json",
|
||||
"options": {
|
||||
"bodyContentType": "json"
|
||||
},
|
||||
"bodyParametersJson": "={{ JSON.stringify($json) }}"
|
||||
},
|
||||
"userMustProvide": [
|
||||
"url - The API endpoint URL",
|
||||
"bodyParametersJson - The JSON data to send"
|
||||
],
|
||||
"tips": [
|
||||
"Use expressions to make values dynamic",
|
||||
"Enable 'Split Into Items' for batch processing"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Common Use Cases
|
||||
1. **Quick task setup**: Configure nodes for specific tasks instantly
|
||||
2. **Learning patterns**: See how to configure nodes properly
|
||||
3. **Common workflows**: Standard patterns like webhooks, API calls, database queries
|
||||
|
||||
### Examples
|
||||
```json
|
||||
// Get configuration for JSON POST request
|
||||
{
|
||||
"task": "post_json_request"
|
||||
}
|
||||
|
||||
// Get webhook receiver configuration
|
||||
{
|
||||
"task": "receive_webhook"
|
||||
}
|
||||
|
||||
// Get AI chat configuration
|
||||
{
|
||||
"task": "chat_with_ai"
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Notes
|
||||
- Instant response (pre-configured templates)
|
||||
- No database lookups required
|
||||
- Includes working examples
|
||||
|
||||
### Best Practices
|
||||
- Use list_tasks first to see available options
|
||||
- Check userMustProvide section
|
||||
- Follow tips for best results
|
||||
- Common tasks: API calls, webhooks, database queries, AI chat
|
||||
|
||||
### Common Pitfalls
|
||||
- Not all tasks available (use list_tasks)
|
||||
- Configuration needs customization
|
||||
- Some fields still need user input
|
||||
|
||||
### Related Tools
|
||||
- `list_tasks` - See all available tasks
|
||||
- `get_node_essentials` - Alternative approach
|
||||
- `search_templates` - Find complete workflow templates
|
||||
|
||||
---
|
||||
|
||||
## n8n_create_workflow
|
||||
|
||||
**Brief Description**: Create a new workflow in n8n with nodes and connections.
|
||||
|
||||
### Parameters
|
||||
- `name` (string, required): Workflow name
|
||||
- `nodes` (array, required): Array of node definitions
|
||||
- `connections` (object, required): Node connections mapping
|
||||
- `settings` (object, optional): Workflow settings
|
||||
|
||||
### Return Format
|
||||
```json
|
||||
{
|
||||
"id": "workflow-uuid",
|
||||
"name": "My Workflow",
|
||||
"active": false,
|
||||
"createdAt": "2024-01-15T10:30:00Z",
|
||||
"updatedAt": "2024-01-15T10:30:00Z",
|
||||
"nodes": [...],
|
||||
"connections": {...}
|
||||
}
|
||||
```
|
||||
|
||||
### Common Use Cases
|
||||
1. **Automated workflow creation**: Build workflows programmatically
|
||||
2. **Template deployment**: Deploy pre-built workflow patterns
|
||||
3. **Multi-workflow systems**: Create interconnected workflows
|
||||
|
||||
### Examples
|
||||
```json
|
||||
// Create simple webhook → HTTP request workflow
|
||||
{
|
||||
"name": "Webhook to API",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "webhook-1",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 2,
|
||||
"position": [250, 300],
|
||||
"parameters": {
|
||||
"path": "/my-webhook",
|
||||
"httpMethod": "POST"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "http-1",
|
||||
"name": "HTTP Request",
|
||||
"type": "n8n-nodes-base.httpRequest",
|
||||
"typeVersion": 4.2,
|
||||
"position": [450, 300],
|
||||
"parameters": {
|
||||
"method": "POST",
|
||||
"url": "https://api.example.com/process",
|
||||
"responseFormat": "json"
|
||||
}
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [[{"node": "HTTP Request", "type": "main", "index": 0}]]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Notes
|
||||
- API call to n8n instance required
|
||||
- Workflow created in inactive state
|
||||
- Must be manually activated in UI
|
||||
|
||||
### Best Practices
|
||||
- Always include typeVersion for nodes
|
||||
- Use node names (not IDs) in connections
|
||||
- Position nodes logically ([x, y] coordinates)
|
||||
- Test with validate_workflow first
|
||||
- Start simple, add complexity gradually
|
||||
|
||||
### Common Pitfalls
|
||||
- Missing typeVersion causes errors
|
||||
- Using node IDs instead of names in connections
|
||||
- Forgetting required node properties
|
||||
- Creating cycles in connections
|
||||
- Workflow can't be activated via API
|
||||
|
||||
### Related Tools
|
||||
- `validate_workflow` - Validate before creating
|
||||
- `n8n_update_partial_workflow` - Modify existing workflows
|
||||
- `n8n_trigger_webhook_workflow` - Execute workflows
|
||||
|
||||
---
|
||||
|
||||
## n8n_update_partial_workflow
|
||||
|
||||
**Brief Description**: Update workflows using diff operations for precise, incremental changes without sending the entire workflow.
|
||||
|
||||
### Parameters
|
||||
- `id` (string, required): Workflow ID to update
|
||||
- `operations` (array, required): Array of diff operations (max 5)
|
||||
- `validateOnly` (boolean, optional): Test without applying changes
|
||||
|
||||
### Return Format
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"workflow": {
|
||||
"id": "workflow-uuid",
|
||||
"name": "Updated Workflow",
|
||||
"nodes": [...],
|
||||
"connections": {...}
|
||||
},
|
||||
"appliedOperations": 3
|
||||
}
|
||||
```
|
||||
|
||||
### Common Use Cases
|
||||
1. **Add nodes to existing workflows**: Insert new functionality
|
||||
2. **Update node configurations**: Change parameters without full replacement
|
||||
3. **Manage connections**: Add/remove node connections
|
||||
4. **Quick edits**: Rename, enable/disable nodes, update settings
|
||||
|
||||
### Examples
|
||||
```json
|
||||
// Add a new node and connect it
|
||||
{
|
||||
"id": "workflow-123",
|
||||
"operations": [
|
||||
{
|
||||
"type": "addNode",
|
||||
"node": {
|
||||
"id": "set-1",
|
||||
"name": "Set Data",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3,
|
||||
"position": [600, 300],
|
||||
"parameters": {
|
||||
"values": {
|
||||
"string": [{
|
||||
"name": "status",
|
||||
"value": "processed"
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "addConnection",
|
||||
"source": "HTTP Request",
|
||||
"target": "Set Data"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Update multiple properties
|
||||
{
|
||||
"id": "workflow-123",
|
||||
"operations": [
|
||||
{
|
||||
"type": "updateName",
|
||||
"name": "Production Workflow v2"
|
||||
},
|
||||
{
|
||||
"type": "updateNode",
|
||||
"nodeName": "Webhook",
|
||||
"changes": {
|
||||
"parameters.path": "/v2/webhook"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "addTag",
|
||||
"tag": "production"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Notes
|
||||
- 80-90% token savings vs full updates
|
||||
- Maximum 5 operations per request
|
||||
- Two-pass processing handles dependencies
|
||||
- Transactional: all or nothing
|
||||
|
||||
### Best Practices
|
||||
- Use validateOnly: true to test first
|
||||
- Keep operations under 5 for reliability
|
||||
- Operations can be in any order (v2.7.0+)
|
||||
- Use node names, not IDs in operations
|
||||
- For updateNode, use dot notation for nested paths
|
||||
|
||||
### Common Pitfalls
|
||||
- Exceeding 5 operations limit
|
||||
- Using node IDs instead of names
|
||||
- Forgetting required node properties in addNode
|
||||
- Not testing with validateOnly first
|
||||
|
||||
### Related Tools
|
||||
- `n8n_update_full_workflow` - Complete workflow replacement
|
||||
- `n8n_get_workflow` - Fetch current workflow state
|
||||
- `validate_workflow` - Validate changes before applying
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Workflow Building Process
|
||||
1. **Discovery**: `search_nodes` → `list_nodes`
|
||||
2. **Configuration**: `get_node_essentials` → `get_node_for_task`
|
||||
3. **Validation**: `validate_node_minimal` → `validate_node_operation`
|
||||
4. **Creation**: `validate_workflow` → `n8n_create_workflow`
|
||||
5. **Updates**: `n8n_update_partial_workflow`
|
||||
|
||||
### Performance Tips
|
||||
- Use `get_node_essentials` instead of `get_node_info` (95% smaller)
|
||||
- Set high limits on `list_nodes` (200+)
|
||||
- Use single words in `search_nodes`
|
||||
- Validate incrementally while building
|
||||
|
||||
### Common Node Types
|
||||
- **Triggers**: webhook, schedule, emailReadImap, slackTrigger
|
||||
- **Core**: httpRequest, code, set, if, merge, splitInBatches
|
||||
- **Integrations**: slack, gmail, googleSheets, postgres, mongodb
|
||||
- **AI**: agent, openAi, chainLlm, documentLoader
|
||||
|
||||
### Error Prevention
|
||||
- Always include node type prefixes: "nodes-base.slack"
|
||||
- Use node names (not IDs) in connections
|
||||
- Include typeVersion in all nodes
|
||||
- Test with validateOnly before applying changes
|
||||
- Check userMustProvide sections in templates
|
||||
@@ -1,514 +0,0 @@
|
||||
# n8n MCP Client Tool Integration - Implementation Plan (Simplified)
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides a **simplified** implementation plan for making n8n-mcp compatible with n8n's MCP Client Tool (v1.1). Based on expert review, we're taking a minimal approach that extends the existing single-session server rather than creating new architecture.
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **Minimal Changes**: Extend existing single-session server with n8n compatibility mode
|
||||
2. **No Overengineering**: No complex session management or multi-session architecture
|
||||
3. **Docker-Native**: Separate Docker image for n8n deployment
|
||||
4. **Remote Deployment**: Designed to run alongside n8n in production
|
||||
5. **Backward Compatible**: Existing functionality remains unchanged
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose
|
||||
- n8n version 1.104.2 or higher (with MCP Client Tool v1.1)
|
||||
- Basic understanding of Docker networking
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
Instead of creating new multi-session architecture, we'll extend the existing single-session server with an n8n compatibility mode. This approach was recommended by all three expert reviewers as simpler and more maintainable.
|
||||
|
||||
## Architecture Changes
|
||||
|
||||
```
|
||||
src/
|
||||
├── http-server-single-session.ts # MODIFY: Add n8n mode flag
|
||||
└── mcp/
|
||||
└── server.ts # NO CHANGES NEEDED
|
||||
|
||||
Docker/
|
||||
├── Dockerfile.n8n # NEW: n8n-specific image
|
||||
├── docker-compose.n8n.yml # NEW: Simplified stack
|
||||
└── .github/workflows/
|
||||
└── docker-build-n8n.yml # NEW: Build workflow
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Modify Existing Single-Session Server
|
||||
|
||||
#### 1.1 Update `src/http-server-single-session.ts`
|
||||
|
||||
Add n8n compatibility mode to the existing server with minimal changes:
|
||||
|
||||
```typescript
|
||||
// Add these constants at the top (after imports)
|
||||
const PROTOCOL_VERSION = "2024-11-05";
|
||||
const N8N_MODE = process.env.N8N_MODE === 'true';
|
||||
|
||||
// In the constructor or start method, add logging
|
||||
if (N8N_MODE) {
|
||||
logger.info('Running in n8n compatibility mode');
|
||||
}
|
||||
|
||||
// In setupRoutes method, add the protocol version endpoint
|
||||
if (N8N_MODE) {
|
||||
app.get('/mcp', (req, res) => {
|
||||
res.json({
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
serverInfo: {
|
||||
name: "n8n-mcp",
|
||||
version: PROJECT_VERSION,
|
||||
capabilities: {
|
||||
tools: true,
|
||||
resources: false,
|
||||
prompts: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// In handleMCPRequest method, add session header
|
||||
if (N8N_MODE && this.session) {
|
||||
res.setHeader('Mcp-Session-Id', this.session.sessionId);
|
||||
}
|
||||
|
||||
// Update error handling to use JSON-RPC format
|
||||
catch (error) {
|
||||
logger.error('MCP request error:', error);
|
||||
|
||||
if (N8N_MODE) {
|
||||
res.status(500).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32603,
|
||||
message: 'Internal error',
|
||||
data: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
id: null,
|
||||
});
|
||||
} else {
|
||||
// Keep existing error handling for backward compatibility
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
That's it! No new files, no complex session management. Just a few lines of code.
|
||||
|
||||
### Step 2: Update Package Scripts
|
||||
|
||||
#### 2.1 Update `package.json`
|
||||
|
||||
Add a simple script for n8n mode:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"start:n8n": "N8N_MODE=true MCP_MODE=http node dist/mcp/index.js"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Create Docker Infrastructure for n8n
|
||||
|
||||
#### 3.1 Create `Dockerfile.n8n`
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile.n8n - Optimized for n8n integration
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json tsconfig*.json ./
|
||||
|
||||
# Install ALL dependencies
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
# Copy source and build
|
||||
COPY src ./src
|
||||
RUN npm run build && npm run rebuild
|
||||
|
||||
# Runtime stage
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache curl dumb-init
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
|
||||
|
||||
# Copy application from builder
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/data ./data
|
||||
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
|
||||
COPY --chown=nodejs:nodejs package.json ./
|
||||
|
||||
USER nodejs
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
HEALTHCHECK CMD curl -f http://localhost:3001/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["node", "dist/mcp/index.js"]
|
||||
```
|
||||
|
||||
#### 3.2 Create `docker-compose.n8n.yml`
|
||||
|
||||
```yaml
|
||||
# docker-compose.n8n.yml - Simple stack for n8n + n8n-mcp
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
n8n:
|
||||
image: n8nio/n8n:latest
|
||||
container_name: n8n
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5678:5678"
|
||||
environment:
|
||||
- N8N_BASIC_AUTH_ACTIVE=${N8N_BASIC_AUTH_ACTIVE:-true}
|
||||
- N8N_BASIC_AUTH_USER=${N8N_USER:-admin}
|
||||
- N8N_BASIC_AUTH_PASSWORD=${N8N_PASSWORD:-changeme}
|
||||
- N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true
|
||||
volumes:
|
||||
- n8n_data:/home/node/.n8n
|
||||
networks:
|
||||
- n8n-net
|
||||
depends_on:
|
||||
n8n-mcp:
|
||||
condition: service_healthy
|
||||
|
||||
n8n-mcp:
|
||||
image: ghcr.io/${GITHUB_USER:-czlonkowski}/n8n-mcp-n8n:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.n8n
|
||||
container_name: n8n-mcp
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- MCP_MODE=http
|
||||
- N8N_MODE=true
|
||||
- AUTH_TOKEN=${MCP_AUTH_TOKEN}
|
||||
- NODE_ENV=production
|
||||
- HTTP_PORT=3001
|
||||
networks:
|
||||
- n8n-net
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
networks:
|
||||
n8n-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
n8n_data:
|
||||
```
|
||||
|
||||
#### 3.3 Create `.env.n8n.example`
|
||||
|
||||
```bash
|
||||
# .env.n8n.example - Copy to .env and configure
|
||||
|
||||
# n8n Configuration
|
||||
N8N_USER=admin
|
||||
N8N_PASSWORD=changeme
|
||||
N8N_BASIC_AUTH_ACTIVE=true
|
||||
|
||||
# MCP Configuration
|
||||
# Generate with: openssl rand -base64 32
|
||||
MCP_AUTH_TOKEN=your-secure-token-minimum-32-characters
|
||||
|
||||
# GitHub username for image registry
|
||||
GITHUB_USER=czlonkowski
|
||||
```
|
||||
|
||||
### Step 4: Create GitHub Actions Workflow
|
||||
|
||||
#### 4.1 Create `.github/workflows/docker-build-n8n.yml`
|
||||
|
||||
```yaml
|
||||
name: Build n8n Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ['v*']
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'package*.json'
|
||||
- 'Dockerfile.n8n'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}-n8n
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: docker/metadata-action@v5
|
||||
id: meta
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.n8n
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
```
|
||||
|
||||
### Step 5: Testing
|
||||
|
||||
#### 5.1 Unit Tests for n8n Mode
|
||||
|
||||
Create `tests/unit/http-server-n8n-mode.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
|
||||
describe('n8n Mode', () => {
|
||||
it('should return protocol version on GET /mcp', async () => {
|
||||
process.env.N8N_MODE = 'true';
|
||||
const app = await createTestApp();
|
||||
|
||||
const response = await request(app)
|
||||
.get('/mcp')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.protocolVersion).toBe('2024-11-05');
|
||||
expect(response.body.serverInfo.capabilities.tools).toBe(true);
|
||||
});
|
||||
|
||||
it('should include session ID in response headers', async () => {
|
||||
process.env.N8N_MODE = 'true';
|
||||
const app = await createTestApp();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/mcp')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.send({ jsonrpc: '2.0', method: 'initialize', id: 1 });
|
||||
|
||||
expect(response.headers['mcp-session-id']).toBeDefined();
|
||||
});
|
||||
|
||||
it('should format errors as JSON-RPC', async () => {
|
||||
process.env.N8N_MODE = 'true';
|
||||
const app = await createTestApp();
|
||||
|
||||
const response = await request(app)
|
||||
.post('/mcp')
|
||||
.send({ invalid: 'request' })
|
||||
.expect(500);
|
||||
|
||||
expect(response.body.jsonrpc).toBe('2.0');
|
||||
expect(response.body.error.code).toBe(-32603);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### 5.2 Quick Deployment Script
|
||||
|
||||
Create `deploy/quick-deploy-n8n.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Quick Deploy n8n + n8n-mcp"
|
||||
|
||||
# Check prerequisites
|
||||
command -v docker >/dev/null 2>&1 || { echo "Docker required"; exit 1; }
|
||||
command -v docker-compose >/dev/null 2>&1 || { echo "Docker Compose required"; exit 1; }
|
||||
|
||||
# Generate auth token if not exists
|
||||
if [ ! -f .env ]; then
|
||||
cp .env.n8n.example .env
|
||||
TOKEN=$(openssl rand -base64 32)
|
||||
sed -i "s/your-secure-token-minimum-32-characters/$TOKEN/" .env
|
||||
echo "Generated MCP_AUTH_TOKEN: $TOKEN"
|
||||
fi
|
||||
|
||||
# Deploy
|
||||
docker-compose -f docker-compose.n8n.yml up -d
|
||||
|
||||
echo ""
|
||||
echo "✅ Deployment complete!"
|
||||
echo ""
|
||||
echo "📋 Next steps:"
|
||||
echo "1. Access n8n at http://localhost:5678"
|
||||
echo " Username: admin (or check .env)"
|
||||
echo " Password: changeme (or check .env)"
|
||||
echo ""
|
||||
echo "2. Create a workflow with MCP Client Tool:"
|
||||
echo " - Server URL: http://n8n-mcp:3001/mcp"
|
||||
echo " - Authentication: Bearer Token"
|
||||
echo " - Token: Check .env file for MCP_AUTH_TOKEN"
|
||||
echo ""
|
||||
echo "📊 View logs: docker-compose -f docker-compose.n8n.yml logs -f"
|
||||
echo "🛑 Stop: docker-compose -f docker-compose.n8n.yml down"
|
||||
```
|
||||
|
||||
## Implementation Checklist (Simplified)
|
||||
|
||||
### Code Changes
|
||||
- [ ] Add N8N_MODE flag to `http-server-single-session.ts`
|
||||
- [ ] Add protocol version endpoint (GET /mcp) when N8N_MODE=true
|
||||
- [ ] Add Mcp-Session-Id header to responses
|
||||
- [ ] Update error responses to JSON-RPC format when N8N_MODE=true
|
||||
- [ ] Add npm script `start:n8n` to package.json
|
||||
|
||||
### Docker Infrastructure
|
||||
- [ ] Create `Dockerfile.n8n` for n8n-specific image
|
||||
- [ ] Create `docker-compose.n8n.yml` for simple deployment
|
||||
- [ ] Create `.env.n8n.example` template
|
||||
- [ ] Create GitHub Actions workflow `docker-build-n8n.yml`
|
||||
- [ ] Create `deploy/quick-deploy-n8n.sh` script
|
||||
|
||||
### Testing
|
||||
- [ ] Write unit tests for n8n mode functionality
|
||||
- [ ] Test with actual n8n MCP Client Tool
|
||||
- [ ] Verify protocol version endpoint
|
||||
- [ ] Test authentication flow
|
||||
- [ ] Validate error formatting
|
||||
|
||||
### Documentation
|
||||
- [ ] Update README with n8n deployment section
|
||||
- [ ] Document N8N_MODE environment variable
|
||||
- [ ] Add troubleshooting guide for common issues
|
||||
|
||||
## Quick Start Guide
|
||||
|
||||
### 1. One-Command Deployment
|
||||
|
||||
```bash
|
||||
# Clone and deploy
|
||||
git clone https://github.com/czlonkowski/n8n-mcp.git
|
||||
cd n8n-mcp
|
||||
./deploy/quick-deploy-n8n.sh
|
||||
```
|
||||
|
||||
### 2. Manual Configuration in n8n
|
||||
|
||||
After deployment, configure the MCP Client Tool in n8n:
|
||||
|
||||
1. Open n8n at `http://localhost:5678`
|
||||
2. Create a new workflow
|
||||
3. Add "MCP Client Tool" node (under AI category)
|
||||
4. Configure:
|
||||
- **Server URL**: `http://n8n-mcp:3001/mcp`
|
||||
- **Authentication**: Bearer Token
|
||||
- **Token**: Check your `.env` file for MCP_AUTH_TOKEN
|
||||
5. Select a tool (e.g., `list_nodes`)
|
||||
6. Execute the workflow
|
||||
|
||||
### 3. Production Deployment
|
||||
|
||||
For production with SSL, use a reverse proxy:
|
||||
|
||||
```nginx
|
||||
# nginx configuration
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name n8n.yourdomain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:5678;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The MCP server should remain internal only - n8n connects via Docker network.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
The implementation is successful when:
|
||||
|
||||
1. **Minimal Code Changes**: Only ~20 lines added to existing server
|
||||
2. **Protocol Compliance**: GET /mcp returns correct protocol version
|
||||
3. **n8n Connection**: MCP Client Tool connects successfully
|
||||
4. **Tool Execution**: Tools work without modification
|
||||
5. **Backward Compatible**: Existing Claude Desktop usage unaffected
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"Protocol version mismatch"**
|
||||
- Ensure N8N_MODE=true is set
|
||||
- Check GET /mcp returns "2024-11-05"
|
||||
|
||||
2. **"Authentication failed"**
|
||||
- Verify AUTH_TOKEN matches in .env and n8n
|
||||
- Token must be 32+ characters
|
||||
- Use "Bearer Token" auth type in n8n
|
||||
|
||||
3. **"Connection refused"**
|
||||
- Check containers are on same network
|
||||
- Use internal hostname: `http://n8n-mcp:3001/mcp`
|
||||
- Verify health check passes
|
||||
|
||||
4. **Testing the Setup**
|
||||
```bash
|
||||
# Check protocol version
|
||||
docker exec n8n-mcp curl http://localhost:3001/mcp
|
||||
|
||||
# View logs
|
||||
docker-compose -f docker-compose.n8n.yml logs -f n8n-mcp
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
This simplified approach:
|
||||
- **Extends existing code** rather than creating new architecture
|
||||
- **Adds n8n compatibility** with minimal changes
|
||||
- **Uses separate Docker image** for clean deployment
|
||||
- **Maintains backward compatibility** for existing users
|
||||
- **Avoids overengineering** with simple, practical solutions
|
||||
|
||||
Total implementation effort: ~2-3 hours (vs. 2-3 days for multi-session approach)
|
||||
@@ -1,146 +0,0 @@
|
||||
# Test Artifacts Documentation
|
||||
|
||||
This document describes the comprehensive test result artifact storage system implemented in the n8n-mcp project.
|
||||
|
||||
## Overview
|
||||
|
||||
The test artifact system captures, stores, and presents test results in multiple formats to facilitate debugging, analysis, and historical tracking of test performance.
|
||||
|
||||
## Artifact Types
|
||||
|
||||
### 1. Test Results
|
||||
- **JUnit XML** (`test-results/junit.xml`): Standard format for CI integration
|
||||
- **JSON Results** (`test-results/results.json`): Detailed test data for analysis
|
||||
- **HTML Report** (`test-results/html/index.html`): Interactive test report
|
||||
- **Test Summary** (`test-summary.md`): Markdown summary for PR comments
|
||||
|
||||
### 2. Coverage Reports
|
||||
- **LCOV** (`coverage/lcov.info`): Standard coverage format
|
||||
- **HTML Coverage** (`coverage/html/index.html`): Interactive coverage browser
|
||||
- **Coverage Summary** (`coverage/coverage-summary.json`): JSON coverage data
|
||||
|
||||
### 3. Benchmark Results
|
||||
- **Benchmark JSON** (`benchmark-results.json`): Raw benchmark data
|
||||
- **Comparison Reports** (`benchmark-comparison.md`): PR benchmark comparisons
|
||||
|
||||
### 4. Detailed Reports
|
||||
- **HTML Report** (`test-reports/report.html`): Comprehensive styled report
|
||||
- **Markdown Report** (`test-reports/report.md`): Full markdown report
|
||||
- **JSON Report** (`test-reports/report.json`): Complete test data
|
||||
|
||||
## GitHub Actions Integration
|
||||
|
||||
### Test Workflow (`test.yml`)
|
||||
|
||||
The main test workflow:
|
||||
1. Runs tests with coverage using multiple reporters
|
||||
2. Generates test summaries and detailed reports
|
||||
3. Uploads artifacts with metadata
|
||||
4. Posts summaries to PRs
|
||||
5. Creates a combined artifact index
|
||||
|
||||
### Benchmark PR Workflow (`benchmark-pr.yml`)
|
||||
|
||||
For pull requests:
|
||||
1. Runs benchmarks on PR branch
|
||||
2. Runs benchmarks on base branch
|
||||
3. Compares results
|
||||
4. Posts comparison to PR
|
||||
5. Sets status checks for regressions
|
||||
|
||||
## Artifact Retention
|
||||
|
||||
- **Test Results**: 30 days
|
||||
- **Coverage Reports**: 30 days
|
||||
- **Benchmark Results**: 30 days
|
||||
- **Combined Results**: 90 days
|
||||
- **Test Metadata**: 30 days
|
||||
|
||||
## PR Comment Integration
|
||||
|
||||
The system automatically:
|
||||
- Posts test summaries to PR comments
|
||||
- Updates existing comments instead of creating duplicates
|
||||
- Includes links to full artifacts
|
||||
- Shows coverage and benchmark changes
|
||||
|
||||
## Job Summary
|
||||
|
||||
Each workflow run includes a job summary with:
|
||||
- Test results overview
|
||||
- Coverage summary
|
||||
- Benchmark results
|
||||
- Direct links to download artifacts
|
||||
|
||||
## Local Development
|
||||
|
||||
### Running Tests with Reports
|
||||
|
||||
```bash
|
||||
# Run tests with all reporters
|
||||
CI=true npm run test:coverage
|
||||
|
||||
# Generate detailed reports
|
||||
node scripts/generate-detailed-reports.js
|
||||
|
||||
# Generate test summary
|
||||
node scripts/generate-test-summary.js
|
||||
|
||||
# Compare benchmarks
|
||||
node scripts/compare-benchmarks.js benchmark-results.json benchmark-baseline.json
|
||||
```
|
||||
|
||||
### Report Locations
|
||||
|
||||
When running locally, reports are generated in:
|
||||
- `test-results/` - Vitest outputs
|
||||
- `test-reports/` - Detailed reports
|
||||
- `coverage/` - Coverage reports
|
||||
- Root directory - Summary files
|
||||
|
||||
## Report Formats
|
||||
|
||||
### HTML Report Features
|
||||
- Responsive design
|
||||
- Test suite breakdown
|
||||
- Failed test details with error messages
|
||||
- Coverage visualization with progress bars
|
||||
- Benchmark performance metrics
|
||||
- Sortable tables
|
||||
|
||||
### Markdown Report Features
|
||||
- GitHub-compatible formatting
|
||||
- Summary statistics
|
||||
- Failed test listings
|
||||
- Coverage breakdown
|
||||
- Benchmark comparisons
|
||||
|
||||
### JSON Report Features
|
||||
- Complete test data
|
||||
- Programmatic access
|
||||
- Historical comparison
|
||||
- CI/CD integration
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always Check Artifacts**: When tests fail in CI, download and review the HTML report
|
||||
2. **Monitor Coverage**: Use the coverage reports to identify untested code
|
||||
3. **Track Benchmarks**: Review benchmark comparisons on performance-critical PRs
|
||||
4. **Archive Important Runs**: Download artifacts from significant releases
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Missing Artifacts
|
||||
- Check if tests ran to completion
|
||||
- Verify artifact upload steps executed
|
||||
- Check retention period hasn't expired
|
||||
|
||||
### Report Generation Failures
|
||||
- Ensure all dependencies are installed
|
||||
- Check for valid test/coverage output files
|
||||
- Review workflow logs for errors
|
||||
|
||||
### PR Comment Issues
|
||||
- Verify GitHub Actions permissions
|
||||
- Check bot authentication
|
||||
- Review comment posting logs
|
||||
@@ -1,935 +0,0 @@
|
||||
# n8n-MCP Testing Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the comprehensive testing infrastructure implemented for the n8n-MCP project. The testing suite includes 3,336 tests split between unit and integration tests, benchmarks, and a complete CI/CD pipeline ensuring code quality and reliability.
|
||||
|
||||
### Test Suite Statistics (October 2025)
|
||||
|
||||
- **Total Tests**: 3,336 tests
|
||||
- **Unit Tests**: 2,766 tests - Isolated component testing with mocks
|
||||
- **Integration Tests**: 570 tests - Full system behavior validation
|
||||
- n8n API Integration: 172 tests (all 18 MCP handler tools)
|
||||
- MCP Protocol: 119 tests (protocol compliance, session management)
|
||||
- Database: 226 tests (repository operations, transactions, FTS5)
|
||||
- Templates: 35 tests (fetching, storage, metadata)
|
||||
- Docker: 18 tests (configuration, security)
|
||||
- **Test Files**:
|
||||
- 106 unit test files
|
||||
- 41 integration test files
|
||||
- Total: 147 test files
|
||||
- **Test Execution Time**:
|
||||
- Unit tests: ~2 minutes with coverage
|
||||
- Integration tests: ~30 seconds
|
||||
- Total CI time: ~3 minutes
|
||||
- **Success Rate**: 100% (all tests passing in CI)
|
||||
- **CI/CD Pipeline**: Fully automated with GitHub Actions
|
||||
- **Test Artifacts**: JUnit XML, coverage reports, benchmark results
|
||||
- **Parallel Execution**: Configurable with thread pool
|
||||
|
||||
## Testing Framework: Vitest
|
||||
|
||||
We use **Vitest** as our primary testing framework, chosen for its:
|
||||
- **Speed**: Native ESM support and fast execution
|
||||
- **TypeScript Integration**: First-class TypeScript support
|
||||
- **Watch Mode**: Instant feedback during development
|
||||
- **Jest Compatibility**: Easy migration from Jest
|
||||
- **Built-in Mocking**: Powerful mocking capabilities
|
||||
- **Coverage**: Integrated code coverage with v8
|
||||
|
||||
### Configuration
|
||||
|
||||
```typescript
|
||||
// vitest.config.ts
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
setupFiles: ['./tests/setup/global-setup.ts'],
|
||||
pool: 'threads',
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: process.env.TEST_PARALLEL !== 'true',
|
||||
maxThreads: parseInt(process.env.TEST_MAX_WORKERS || '4', 10)
|
||||
}
|
||||
},
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['lcov', 'html', 'text-summary'],
|
||||
exclude: ['node_modules/', 'tests/', '**/*.test.ts', 'scripts/']
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@tests': path.resolve(__dirname, './tests')
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── unit/ # Unit tests with mocks (2,766 tests, 106 files)
|
||||
│ ├── __mocks__/ # Mock implementations
|
||||
│ │ └── n8n-nodes-base.test.ts
|
||||
│ ├── database/ # Database layer tests
|
||||
│ │ ├── database-adapter-unit.test.ts
|
||||
│ │ ├── node-repository-core.test.ts
|
||||
│ │ └── template-repository-core.test.ts
|
||||
│ ├── docker/ # Docker configuration tests
|
||||
│ │ ├── config-security.test.ts
|
||||
│ │ ├── edge-cases.test.ts
|
||||
│ │ ├── parse-config.test.ts
|
||||
│ │ └── serve-command.test.ts
|
||||
│ ├── http-server/ # HTTP server tests
|
||||
│ │ └── multi-tenant-support.test.ts
|
||||
│ ├── loaders/ # Node loader tests
|
||||
│ │ └── node-loader.test.ts
|
||||
│ ├── mappers/ # Data mapper tests
|
||||
│ │ └── docs-mapper.test.ts
|
||||
│ ├── mcp/ # MCP server and tools tests
|
||||
│ │ ├── handlers-n8n-manager.test.ts
|
||||
│ │ ├── handlers-workflow-diff.test.ts
|
||||
│ │ ├── tools-documentation.test.ts
|
||||
│ │ └── tools.test.ts
|
||||
│ ├── parsers/ # Parser tests
|
||||
│ │ ├── node-parser.test.ts
|
||||
│ │ ├── property-extractor.test.ts
|
||||
│ │ └── simple-parser.test.ts
|
||||
│ ├── scripts/ # Script tests
|
||||
│ │ └── fetch-templates-extraction.test.ts
|
||||
│ ├── services/ # Service layer tests (largest test suite)
|
||||
│ │ ├── config-validator.test.ts
|
||||
│ │ ├── enhanced-config-validator.test.ts
|
||||
│ │ ├── example-generator.test.ts
|
||||
│ │ ├── expression-validator.test.ts
|
||||
│ │ ├── n8n-api-client.test.ts
|
||||
│ │ ├── n8n-validation.test.ts
|
||||
│ │ ├── node-specific-validators.test.ts
|
||||
│ │ ├── property-dependencies.test.ts
|
||||
│ │ ├── property-filter.test.ts
|
||||
│ │ ├── task-templates.test.ts
|
||||
│ │ ├── workflow-diff-engine.test.ts
|
||||
│ │ ├── workflow-validator-comprehensive.test.ts
|
||||
│ │ └── workflow-validator.test.ts
|
||||
│ ├── telemetry/ # Telemetry tests
|
||||
│ │ └── telemetry-manager.test.ts
|
||||
│ └── utils/ # Utility function tests
|
||||
│ ├── cache-utils.test.ts
|
||||
│ └── database-utils.test.ts
|
||||
├── integration/ # Integration tests (570 tests, 41 files)
|
||||
│ ├── n8n-api/ # n8n API integration tests (172 tests, 18 files)
|
||||
│ │ ├── executions/ # Execution management tests
|
||||
│ │ │ ├── get-execution.test.ts
|
||||
│ │ │ └── list-executions.test.ts
|
||||
│ │ ├── system/ # System tool tests
|
||||
│ │ │ ├── diagnostic.test.ts
|
||||
│ │ │ ├── health-check.test.ts
|
||||
│ │ │ └── list-tools.test.ts
|
||||
│ │ ├── utils/ # Test utilities
|
||||
│ │ │ ├── mcp-context.ts
|
||||
│ │ │ └── response-types.ts
|
||||
│ │ └── workflows/ # Workflow management tests
|
||||
│ │ ├── autofix-workflow.test.ts
|
||||
│ │ ├── create-workflow.test.ts
|
||||
│ │ ├── delete-workflow.test.ts
|
||||
│ │ ├── get-workflow-details.test.ts
|
||||
│ │ ├── get-workflow-minimal.test.ts
|
||||
│ │ ├── get-workflow-structure.test.ts
|
||||
│ │ ├── get-workflow.test.ts
|
||||
│ │ ├── list-workflows.test.ts
|
||||
│ │ ├── update-full-workflow.test.ts
|
||||
│ │ ├── update-partial-workflow.test.ts
|
||||
│ │ └── validate-workflow.test.ts
|
||||
│ ├── database/ # Database integration tests (226 tests)
|
||||
│ │ ├── connection-management.test.ts
|
||||
│ │ ├── fts5-search.test.ts
|
||||
│ │ ├── node-repository.test.ts
|
||||
│ │ ├── performance.test.ts
|
||||
│ │ ├── template-node-configs.test.ts
|
||||
│ │ ├── template-repository.test.ts
|
||||
│ │ └── transactions.test.ts
|
||||
│ ├── docker/ # Docker integration tests (18 tests)
|
||||
│ │ ├── docker-config.test.ts
|
||||
│ │ └── docker-entrypoint.test.ts
|
||||
│ ├── mcp-protocol/ # MCP protocol tests (119 tests)
|
||||
│ │ ├── basic-connection.test.ts
|
||||
│ │ ├── error-handling.test.ts
|
||||
│ │ ├── performance.test.ts
|
||||
│ │ ├── protocol-compliance.test.ts
|
||||
│ │ ├── session-management.test.ts
|
||||
│ │ ├── tool-invocation.test.ts
|
||||
│ │ └── workflow-error-validation.test.ts
|
||||
│ ├── templates/ # Template tests (35 tests)
|
||||
│ │ └── metadata-operations.test.ts
|
||||
│ └── setup/ # Integration test setup
|
||||
│ ├── integration-setup.ts
|
||||
│ └── msw-test-server.ts
|
||||
├── benchmarks/ # Performance benchmarks
|
||||
│ ├── database-queries.bench.ts
|
||||
│ └── sample.bench.ts
|
||||
├── setup/ # Global test configuration
|
||||
│ ├── global-setup.ts # Global test setup
|
||||
│ ├── msw-setup.ts # Mock Service Worker setup
|
||||
│ └── test-env.ts # Test environment configuration
|
||||
├── utils/ # Test utilities
|
||||
│ ├── assertions.ts # Custom assertions
|
||||
│ ├── builders/ # Test data builders
|
||||
│ │ └── workflow.builder.ts
|
||||
│ ├── data-generators.ts # Test data generators
|
||||
│ ├── database-utils.ts # Database test utilities
|
||||
│ └── test-helpers.ts # General test helpers
|
||||
├── mocks/ # Mock implementations
|
||||
│ └── n8n-api/ # n8n API mocks
|
||||
│ ├── handlers.ts # MSW request handlers
|
||||
│ └── data/ # Mock data
|
||||
└── fixtures/ # Test fixtures
|
||||
├── database/ # Database fixtures
|
||||
├── factories/ # Data factories
|
||||
└── workflows/ # Workflow fixtures
|
||||
```
|
||||
|
||||
## Mock Strategy
|
||||
|
||||
### 1. Mock Service Worker (MSW) for API Mocking
|
||||
|
||||
We use MSW for intercepting and mocking HTTP requests:
|
||||
|
||||
```typescript
|
||||
// tests/mocks/n8n-api/handlers.ts
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
export const handlers = [
|
||||
// Workflow endpoints
|
||||
http.get('*/workflows/:id', ({ params }) => {
|
||||
const workflow = mockWorkflows.find(w => w.id === params.id);
|
||||
if (!workflow) {
|
||||
return new HttpResponse(null, { status: 404 });
|
||||
}
|
||||
return HttpResponse.json(workflow);
|
||||
}),
|
||||
|
||||
// Execution endpoints
|
||||
http.post('*/workflows/:id/run', async ({ params, request }) => {
|
||||
const body = await request.json();
|
||||
return HttpResponse.json({
|
||||
executionId: generateExecutionId(),
|
||||
status: 'running'
|
||||
});
|
||||
})
|
||||
];
|
||||
```
|
||||
|
||||
### 2. Database Mocking
|
||||
|
||||
For unit tests, we mock the database layer:
|
||||
|
||||
```typescript
|
||||
// tests/unit/__mocks__/better-sqlite3.ts
|
||||
import { vi } from 'vitest';
|
||||
|
||||
export default vi.fn(() => ({
|
||||
prepare: vi.fn(() => ({
|
||||
all: vi.fn().mockReturnValue([]),
|
||||
get: vi.fn().mockReturnValue(undefined),
|
||||
run: vi.fn().mockReturnValue({ changes: 1 }),
|
||||
finalize: vi.fn()
|
||||
})),
|
||||
exec: vi.fn(),
|
||||
close: vi.fn(),
|
||||
pragma: vi.fn()
|
||||
}));
|
||||
```
|
||||
|
||||
### 3. MCP SDK Mocking
|
||||
|
||||
For testing MCP protocol interactions:
|
||||
|
||||
```typescript
|
||||
// tests/integration/mcp-protocol/test-helpers.ts
|
||||
export class TestableN8NMCPServer extends N8NMCPServer {
|
||||
private transports = new Set<Transport>();
|
||||
|
||||
async connectToTransport(transport: Transport): Promise<void> {
|
||||
this.transports.add(transport);
|
||||
await this.connect(transport);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
for (const transport of this.transports) {
|
||||
await transport.close();
|
||||
}
|
||||
this.transports.clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Test Patterns and Utilities
|
||||
|
||||
### 1. Database Test Utilities
|
||||
|
||||
```typescript
|
||||
// tests/utils/database-utils.ts
|
||||
export class TestDatabase {
|
||||
constructor(options: TestDatabaseOptions = {}) {
|
||||
this.options = {
|
||||
mode: 'memory',
|
||||
enableFTS5: true,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
async initialize(): Promise<Database.Database> {
|
||||
const db = this.options.mode === 'memory'
|
||||
? new Database(':memory:')
|
||||
: new Database(this.dbPath);
|
||||
|
||||
if (this.options.enableFTS5) {
|
||||
await this.enableFTS5(db);
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Data Generators
|
||||
|
||||
```typescript
|
||||
// tests/utils/data-generators.ts
|
||||
export class TestDataGenerator {
|
||||
static generateNode(overrides: Partial<ParsedNode> = {}): ParsedNode {
|
||||
return {
|
||||
nodeType: `test.node${faker.number.int()}`,
|
||||
displayName: faker.commerce.productName(),
|
||||
description: faker.lorem.sentence(),
|
||||
properties: this.generateProperties(5),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
static generateWorkflow(nodeCount = 3): any {
|
||||
const nodes = Array.from({ length: nodeCount }, (_, i) => ({
|
||||
id: `node_${i}`,
|
||||
type: 'test.node',
|
||||
position: [i * 100, 0],
|
||||
parameters: {}
|
||||
}));
|
||||
|
||||
return { nodes, connections: {} };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Custom Assertions
|
||||
|
||||
```typescript
|
||||
// tests/utils/assertions.ts
|
||||
export function expectValidMCPResponse(response: any): void {
|
||||
expect(response).toBeDefined();
|
||||
expect(response.content).toBeDefined();
|
||||
expect(Array.isArray(response.content)).toBe(true);
|
||||
expect(response.content[0]).toHaveProperty('type', 'text');
|
||||
expect(response.content[0]).toHaveProperty('text');
|
||||
}
|
||||
|
||||
export function expectNodeStructure(node: any): void {
|
||||
expect(node).toHaveProperty('nodeType');
|
||||
expect(node).toHaveProperty('displayName');
|
||||
expect(node).toHaveProperty('properties');
|
||||
expect(Array.isArray(node.properties)).toBe(true);
|
||||
}
|
||||
```
|
||||
|
||||
## Unit Testing
|
||||
|
||||
Our unit tests focus on testing individual components in isolation with mocked dependencies:
|
||||
|
||||
### Service Layer Tests
|
||||
|
||||
The bulk of our unit tests (400+ tests) are in the services layer:
|
||||
|
||||
```typescript
|
||||
// tests/unit/services/workflow-validator-comprehensive.test.ts
|
||||
describe('WorkflowValidator Comprehensive Tests', () => {
|
||||
it('should validate complex workflow with AI nodes', () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: 'ai_agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
parameters: { prompt: 'Analyze data' }
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const result = validator.validateWorkflow(workflow);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Parser Tests
|
||||
|
||||
Testing the node parsing logic:
|
||||
|
||||
```typescript
|
||||
// tests/unit/parsers/property-extractor.test.ts
|
||||
describe('PropertyExtractor', () => {
|
||||
it('should extract nested properties correctly', () => {
|
||||
const node = {
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Options',
|
||||
name: 'options',
|
||||
type: 'collection',
|
||||
options: [
|
||||
{ name: 'timeout', type: 'number' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const extracted = extractor.extractProperties(node);
|
||||
expect(extracted).toHaveProperty('options.timeout');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Mock Testing
|
||||
|
||||
Testing our mock implementations:
|
||||
|
||||
```typescript
|
||||
// tests/unit/__mocks__/n8n-nodes-base.test.ts
|
||||
describe('n8n-nodes-base mock', () => {
|
||||
it('should provide mocked node definitions', () => {
|
||||
const httpNode = mockNodes['n8n-nodes-base.httpRequest'];
|
||||
expect(httpNode).toBeDefined();
|
||||
expect(httpNode.description.displayName).toBe('HTTP Request');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Integration Testing
|
||||
|
||||
Our integration tests verify the complete system behavior across 570 tests in four major categories:
|
||||
|
||||
### n8n API Integration Testing (172 tests)
|
||||
|
||||
The n8n API integration tests verify all 18 MCP handler tools against a real n8n instance. These tests ensure our product layer (MCP handlers) work correctly end-to-end, not just the raw API client.
|
||||
|
||||
**Test Organization:**
|
||||
- **Workflows** (11 handlers): Create, read, update (full/partial), delete, list, validate, autofix
|
||||
- **Executions** (2 handlers): Get execution details, list executions
|
||||
- **System** (3 handlers): Health check, list available tools, diagnostics
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// tests/integration/n8n-api/workflows/create-workflow.test.ts
|
||||
describe('Integration: handleCreateWorkflow', () => {
|
||||
it('should create a simple two-node workflow', async () => {
|
||||
const response = await handleCreateWorkflow(
|
||||
{
|
||||
params: {
|
||||
arguments: {
|
||||
name: 'Test Workflow',
|
||||
nodes: [webhook, setNode],
|
||||
connections: { Webhook: { main: [[{ node: 'Set', type: 'main', index: 0 }]] } }
|
||||
}
|
||||
}
|
||||
},
|
||||
mcpContext
|
||||
);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
const workflow = response.data as WorkflowData;
|
||||
expect(workflow.id).toBeDefined();
|
||||
expect(workflow.nodes).toHaveLength(2);
|
||||
|
||||
// Cleanup
|
||||
await handleDeleteWorkflow({ params: { arguments: { id: workflow.id } } }, mcpContext);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Key Features Tested:**
|
||||
- Real workflow creation, modification, deletion with cleanup
|
||||
- TypeScript type safety with response interfaces
|
||||
- Complete coverage of all 18 n8n API tools
|
||||
- Proper error handling and edge cases
|
||||
- Response format validation
|
||||
|
||||
### MCP Protocol Testing (119 tests)
|
||||
|
||||
```typescript
|
||||
// tests/integration/mcp-protocol/tool-invocation.test.ts
|
||||
describe('MCP Tool Invocation', () => {
|
||||
let mcpServer: TestableN8NMCPServer;
|
||||
let client: Client;
|
||||
|
||||
beforeEach(async () => {
|
||||
mcpServer = new TestableN8NMCPServer();
|
||||
await mcpServer.initialize();
|
||||
|
||||
const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
|
||||
await mcpServer.connectToTransport(serverTransport);
|
||||
|
||||
client = new Client({ name: 'test-client', version: '1.0.0' }, {});
|
||||
await client.connect(clientTransport);
|
||||
});
|
||||
|
||||
it('should list nodes with filtering', async () => {
|
||||
const response = await client.callTool({
|
||||
name: 'list_nodes',
|
||||
arguments: { category: 'trigger', limit: 10 }
|
||||
});
|
||||
|
||||
expectValidMCPResponse(response);
|
||||
const result = JSON.parse(response.content[0].text);
|
||||
expect(result.nodes).toHaveLength(10);
|
||||
expect(result.nodes.every(n => n.category === 'trigger')).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Database Integration Testing (226 tests)
|
||||
|
||||
```typescript
|
||||
// tests/integration/database/fts5-search.test.ts
|
||||
describe('FTS5 Search Integration', () => {
|
||||
it('should perform fuzzy search', async () => {
|
||||
const results = await nodeRepo.searchNodes('HTT', 'FUZZY');
|
||||
|
||||
expect(results.some(n => n.nodeType.includes('httpRequest'))).toBe(true);
|
||||
expect(results.some(n => n.displayName.includes('HTTP'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle complex boolean queries', async () => {
|
||||
const results = await nodeRepo.searchNodes('webhook OR http', 'OR');
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results.some(n =>
|
||||
n.description?.includes('webhook') ||
|
||||
n.description?.includes('http')
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Template Integration Testing (35 tests)
|
||||
|
||||
Tests template fetching, storage, and metadata operations against the n8n.io API and local database.
|
||||
|
||||
### Docker Integration Testing (18 tests)
|
||||
|
||||
Tests Docker configuration parsing, entrypoint script, and security validation.
|
||||
|
||||
## Test Distribution and Coverage
|
||||
|
||||
### Test Distribution by Component
|
||||
|
||||
Based on our 3,336 tests:
|
||||
|
||||
**Integration Tests (570 tests):**
|
||||
1. **n8n API Integration** (172 tests)
|
||||
- Workflow management handlers: 11 tools with comprehensive scenarios
|
||||
- Execution management handlers: 2 tools
|
||||
- System tool handlers: 3 tools
|
||||
- TypeScript type safety with response interfaces
|
||||
|
||||
2. **Database Integration** (226 tests)
|
||||
- Repository operations and transactions
|
||||
- FTS5 full-text search with fuzzy matching
|
||||
- Performance and concurrent access tests
|
||||
- Template node configurations
|
||||
|
||||
3. **MCP Protocol** (119 tests)
|
||||
- Protocol compliance and session management
|
||||
- Tool invocation and error handling
|
||||
- Performance and stress testing
|
||||
- Workflow error validation
|
||||
|
||||
4. **Templates & Docker** (53 tests)
|
||||
- Template fetching and metadata operations
|
||||
- Docker configuration and security validation
|
||||
|
||||
**Unit Tests (2,766 tests):**
|
||||
1. **Services Layer** (largest suite)
|
||||
- `workflow-validator-comprehensive.test.ts`: 150+ tests
|
||||
- `enhanced-config-validator.test.ts`: 120+ tests
|
||||
- `node-specific-validators.test.ts`: 100+ tests
|
||||
- `n8n-api-client.test.ts`: 80+ tests
|
||||
- Config validation, property filtering, workflow diff engine
|
||||
|
||||
2. **Parsers** (~200 tests)
|
||||
- Node parsing with version support
|
||||
- Property extraction and documentation mapping
|
||||
- Simple parser for basic node information
|
||||
|
||||
3. **Database Layer** (~150 tests)
|
||||
- Repository core functionality with mocks
|
||||
- Database adapter unit tests
|
||||
- Template repository operations
|
||||
|
||||
4. **MCP Tools & HTTP Server** (~300 tests)
|
||||
- Tool definitions and documentation system
|
||||
- Multi-tenant support and security
|
||||
- Configuration validation
|
||||
|
||||
5. **Utils, Docker, Scripts, Telemetry** (remaining tests)
|
||||
- Cache utilities, database helpers
|
||||
- Docker config security and parsing
|
||||
- Template extraction scripts
|
||||
- Telemetry tracking
|
||||
|
||||
### Test Execution Performance
|
||||
|
||||
From our CI runs:
|
||||
- **Fastest tests**: Unit tests with mocks (<1ms each)
|
||||
- **Slowest tests**: Integration tests with real database and n8n API (100-5000ms)
|
||||
- **Average test time**: ~20ms per test
|
||||
- **Total suite execution**: ~3 minutes in CI (with coverage)
|
||||
- **Parallel execution**: Configurable thread pool for optimal performance
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
Our GitHub Actions workflow runs all tests automatically:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
name: Test Suite
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
run: npm run test:unit -- --coverage
|
||||
|
||||
- name: Run integration tests
|
||||
run: npm run test:integration
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
```
|
||||
|
||||
### Test Execution Scripts
|
||||
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"scripts": {
|
||||
"test": "vitest",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration --config vitest.config.integration.ts",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:watch": "vitest watch",
|
||||
"test:bench": "vitest bench --config vitest.config.benchmark.ts",
|
||||
"benchmark:ci": "CI=true node scripts/run-benchmarks-ci.js"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CI Test Results Summary
|
||||
|
||||
From our latest CI run (#41):
|
||||
|
||||
```
|
||||
UNIT TESTS:
|
||||
Test Files 30 passed (30)
|
||||
Tests 932 passed | 1 skipped (933)
|
||||
|
||||
INTEGRATION TESTS:
|
||||
Test Files 14 passed (14)
|
||||
Tests 245 passed | 4 skipped (249)
|
||||
|
||||
TOTAL: 1,177 passed | 5 skipped | 0 failed
|
||||
```
|
||||
|
||||
## Performance Testing
|
||||
|
||||
We use Vitest's built-in benchmark functionality:
|
||||
|
||||
```typescript
|
||||
// tests/benchmarks/database-queries.bench.ts
|
||||
import { bench, describe } from 'vitest';
|
||||
|
||||
describe('Database Query Performance', () => {
|
||||
bench('search nodes by category', async () => {
|
||||
await nodeRepo.getNodesByCategory('trigger');
|
||||
});
|
||||
|
||||
bench('FTS5 search performance', async () => {
|
||||
await nodeRepo.searchNodes('webhook http request', 'AND');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
Test environment is configured via `.env.test`:
|
||||
|
||||
```bash
|
||||
# Test Environment Configuration
|
||||
NODE_ENV=test
|
||||
TEST_DB_PATH=:memory:
|
||||
TEST_PARALLEL=false
|
||||
TEST_MAX_WORKERS=4
|
||||
FEATURE_TEST_COVERAGE=true
|
||||
MSW_ENABLED=true
|
||||
```
|
||||
|
||||
## Key Patterns and Lessons Learned
|
||||
|
||||
### 1. Response Structure Consistency
|
||||
|
||||
All MCP responses follow a specific structure that must be handled correctly:
|
||||
|
||||
```typescript
|
||||
// Common pattern for handling MCP responses
|
||||
const response = await client.callTool({ name: 'list_nodes', arguments: {} });
|
||||
|
||||
// MCP responses have content array with text objects
|
||||
expect(response.content).toBeDefined();
|
||||
expect(response.content[0].type).toBe('text');
|
||||
|
||||
// Parse the actual data
|
||||
const data = JSON.parse(response.content[0].text);
|
||||
```
|
||||
|
||||
### 2. MSW Integration Setup
|
||||
|
||||
Proper MSW setup is crucial for integration tests:
|
||||
|
||||
```typescript
|
||||
// tests/integration/setup/integration-setup.ts
|
||||
import { setupServer } from 'msw/node';
|
||||
import { handlers } from '@tests/mocks/n8n-api/handlers';
|
||||
|
||||
// Create server but don't start it globally
|
||||
const server = setupServer(...handlers);
|
||||
|
||||
beforeAll(async () => {
|
||||
// Only start MSW for integration tests
|
||||
if (process.env.MSW_ENABLED === 'true') {
|
||||
server.listen({ onUnhandledRequest: 'bypass' });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
server.close();
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Database Isolation for Parallel Tests
|
||||
|
||||
Each test gets its own database to enable parallel execution:
|
||||
|
||||
```typescript
|
||||
// tests/utils/database-utils.ts
|
||||
export function createTestDatabaseAdapter(
|
||||
db?: Database.Database,
|
||||
options: TestDatabaseOptions = {}
|
||||
): DatabaseAdapter {
|
||||
const database = db || new Database(':memory:');
|
||||
|
||||
// Enable FTS5 if needed
|
||||
if (options.enableFTS5) {
|
||||
database.exec('PRAGMA main.compile_options;');
|
||||
}
|
||||
|
||||
return new DatabaseAdapter(database);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Environment-Aware Performance Thresholds
|
||||
|
||||
CI environments are slower, so we adjust expectations:
|
||||
|
||||
```typescript
|
||||
// Environment-aware thresholds
|
||||
const getThreshold = (local: number, ci: number) =>
|
||||
process.env.CI ? ci : local;
|
||||
|
||||
it('should respond quickly', async () => {
|
||||
const start = performance.now();
|
||||
await someOperation();
|
||||
const duration = performance.now() - start;
|
||||
|
||||
expect(duration).toBeLessThan(getThreshold(50, 200));
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Test Isolation
|
||||
- Each test creates its own database instance
|
||||
- Tests clean up after themselves
|
||||
- No shared state between tests
|
||||
|
||||
### 2. Proper Cleanup Order
|
||||
```typescript
|
||||
afterEach(async () => {
|
||||
// Close client first to ensure no pending requests
|
||||
await client.close();
|
||||
|
||||
// Give time for client to fully close
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// Then close server
|
||||
await mcpServer.close();
|
||||
|
||||
// Finally cleanup database
|
||||
await testDb.cleanup();
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Handle Async Operations Carefully
|
||||
```typescript
|
||||
// Avoid race conditions in cleanup
|
||||
it('should handle disconnection', async () => {
|
||||
// ... test code ...
|
||||
|
||||
// Ensure operations complete before cleanup
|
||||
await transport.close();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Meaningful Test Organization
|
||||
- Group related tests using `describe` blocks
|
||||
- Use descriptive test names that explain the behavior
|
||||
- Follow AAA pattern: Arrange, Act, Assert
|
||||
- Keep tests focused on single behaviors
|
||||
|
||||
## Debugging Tests
|
||||
|
||||
### Running Specific Tests
|
||||
```bash
|
||||
# Run a single test file
|
||||
npm test tests/integration/mcp-protocol/tool-invocation.test.ts
|
||||
|
||||
# Run tests matching a pattern
|
||||
npm test -- --grep "should list nodes"
|
||||
|
||||
# Run with debugging output
|
||||
DEBUG=* npm test
|
||||
```
|
||||
|
||||
### VSCode Integration
|
||||
```json
|
||||
// .vscode/launch.json
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug Tests",
|
||||
"program": "${workspaceFolder}/node_modules/vitest/vitest.mjs",
|
||||
"args": ["run", "${file}"],
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
While we don't enforce strict coverage thresholds yet, the infrastructure is in place:
|
||||
- Coverage reports generated in `lcov`, `html`, and `text` formats
|
||||
- Integration with Codecov for tracking coverage over time
|
||||
- Per-file coverage visible in VSCode with extensions
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **E2E Testing**: Add Playwright for testing the full MCP server interaction
|
||||
2. **Load Testing**: Implement k6 or Artillery for stress testing
|
||||
3. **Contract Testing**: Add Pact for ensuring API compatibility
|
||||
4. **Visual Regression**: For any UI components that may be added
|
||||
5. **Mutation Testing**: Use Stryker to ensure test quality
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### 1. Tests Hanging in CI
|
||||
|
||||
**Problem**: Tests would hang indefinitely in CI due to `process.exit()` calls.
|
||||
|
||||
**Solution**: Remove all `process.exit()` calls from test code and use proper cleanup:
|
||||
```typescript
|
||||
// Bad
|
||||
afterAll(() => {
|
||||
process.exit(0); // This causes Vitest to hang
|
||||
});
|
||||
|
||||
// Good
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
// Let Vitest handle process termination
|
||||
});
|
||||
```
|
||||
|
||||
### 2. MCP Response Structure
|
||||
|
||||
**Problem**: Tests expecting wrong response format from MCP tools.
|
||||
|
||||
**Solution**: Always access responses through `content[0].text`:
|
||||
```typescript
|
||||
// Wrong
|
||||
const data = response[0].text;
|
||||
|
||||
// Correct
|
||||
const data = JSON.parse(response.content[0].text);
|
||||
```
|
||||
|
||||
### 3. Database Not Found Errors
|
||||
|
||||
**Problem**: Tests failing with "node not found" when database is empty.
|
||||
|
||||
**Solution**: Check for empty databases before assertions:
|
||||
```typescript
|
||||
const stats = await server.executeTool('get_database_statistics', {});
|
||||
if (stats.totalNodes > 0) {
|
||||
expect(result.nodes.length).toBeGreaterThan(0);
|
||||
} else {
|
||||
expect(result.nodes).toHaveLength(0);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. MSW Loading Globally
|
||||
|
||||
**Problem**: MSW interfering with unit tests when loaded globally.
|
||||
|
||||
**Solution**: Only load MSW in integration test setup:
|
||||
```typescript
|
||||
// vitest.config.integration.ts
|
||||
setupFiles: [
|
||||
'./tests/setup/global-setup.ts',
|
||||
'./tests/integration/setup/integration-setup.ts' // MSW only here
|
||||
]
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Vitest Documentation](https://vitest.dev/)
|
||||
- [MSW Documentation](https://mswjs.io/)
|
||||
- [Testing Best Practices](https://github.com/goldbergyoni/javascript-testing-best-practices)
|
||||
- [MCP SDK Documentation](https://modelcontextprotocol.io/)
|
||||
@@ -1,276 +0,0 @@
|
||||
# n8n-MCP Testing Implementation Checklist
|
||||
|
||||
## Test Suite Development Status
|
||||
|
||||
### Context
|
||||
- **Situation**: Building comprehensive test suite from scratch
|
||||
- **Branch**: feat/comprehensive-testing-suite (separate from main)
|
||||
- **Main Branch Status**: Working in production without tests
|
||||
- **Goal**: Add test coverage without disrupting development
|
||||
|
||||
## Immediate Actions (Day 1)
|
||||
|
||||
- [x] ~~Fix failing tests (Phase 0)~~ ✅ COMPLETED
|
||||
- [x] ~~Create GitHub Actions workflow file~~ ✅ COMPLETED
|
||||
- [x] ~~Install Vitest and remove Jest~~ ✅ COMPLETED
|
||||
- [x] ~~Create vitest.config.ts~~ ✅ COMPLETED
|
||||
- [x] ~~Setup global test configuration~~ ✅ COMPLETED
|
||||
- [x] ~~Migrate existing tests to Vitest syntax~~ ✅ COMPLETED
|
||||
- [x] ~~Setup coverage reporting with Codecov~~ ✅ COMPLETED
|
||||
|
||||
## Phase 1: Vitest Migration ✅ COMPLETED
|
||||
|
||||
All tests have been successfully migrated from Jest to Vitest:
|
||||
- ✅ Removed Jest and installed Vitest
|
||||
- ✅ Created vitest.config.ts with path aliases
|
||||
- ✅ Set up global test configuration
|
||||
- ✅ Migrated all 6 test files (68 tests passing)
|
||||
- ✅ Updated TypeScript configuration
|
||||
- ✅ Cleaned up Jest configuration files
|
||||
|
||||
## Week 1: Foundation
|
||||
|
||||
### Testing Infrastructure ✅ COMPLETED (Phase 2)
|
||||
- [x] ~~Create test directory structure~~ ✅ COMPLETED
|
||||
- [x] ~~Setup mock infrastructure for better-sqlite3~~ ✅ COMPLETED
|
||||
- [x] ~~Create mock for n8n-nodes-base package~~ ✅ COMPLETED
|
||||
- [x] ~~Setup test database utilities~~ ✅ COMPLETED
|
||||
- [x] ~~Create factory pattern for nodes~~ ✅ COMPLETED
|
||||
- [x] ~~Create builder pattern for workflows~~ ✅ COMPLETED
|
||||
- [x] ~~Setup global test utilities~~ ✅ COMPLETED
|
||||
- [x] ~~Configure test environment variables~~ ✅ COMPLETED
|
||||
|
||||
### CI/CD Pipeline ✅ COMPLETED (Phase 3.8)
|
||||
- [x] ~~GitHub Actions for test execution~~ ✅ COMPLETED & VERIFIED
|
||||
- Successfully running with Vitest
|
||||
- 1021 tests passing in CI
|
||||
- Build time: ~2 minutes
|
||||
- [x] ~~Coverage reporting integration~~ ✅ COMPLETED (Codecov setup)
|
||||
- [x] ~~Performance benchmark tracking~~ ✅ COMPLETED
|
||||
- [x] ~~Test result artifacts~~ ✅ COMPLETED
|
||||
- [ ] Branch protection rules
|
||||
- [ ] Required status checks
|
||||
|
||||
## Week 2: Mock Infrastructure
|
||||
|
||||
### Database Mocking
|
||||
- [ ] Complete better-sqlite3 mock implementation
|
||||
- [ ] Mock prepared statements
|
||||
- [ ] Mock transactions
|
||||
- [ ] Mock FTS5 search functionality
|
||||
- [ ] Test data seeding utilities
|
||||
|
||||
### External Dependencies
|
||||
- [ ] Mock axios for API calls
|
||||
- [ ] Mock file system operations
|
||||
- [ ] Mock MCP SDK
|
||||
- [ ] Mock Express server
|
||||
- [ ] Mock WebSocket connections
|
||||
|
||||
## Week 3-4: Unit Tests ✅ COMPLETED (Phase 3)
|
||||
|
||||
### Core Services (Priority 1) ✅ COMPLETED
|
||||
- [x] ~~`config-validator.ts` - 95% coverage~~ ✅ 96.9%
|
||||
- [x] ~~`enhanced-config-validator.ts` - 95% coverage~~ ✅ 94.55%
|
||||
- [x] ~~`workflow-validator.ts` - 90% coverage~~ ✅ 97.59%
|
||||
- [x] ~~`expression-validator.ts` - 90% coverage~~ ✅ 97.22%
|
||||
- [x] ~~`property-filter.ts` - 90% coverage~~ ✅ 95.25%
|
||||
- [x] ~~`example-generator.ts` - 85% coverage~~ ✅ 94.34%
|
||||
|
||||
### Parsers (Priority 2) ✅ COMPLETED
|
||||
- [x] ~~`node-parser.ts` - 90% coverage~~ ✅ 97.42%
|
||||
- [x] ~~`property-extractor.ts` - 90% coverage~~ ✅ 95.49%
|
||||
|
||||
### MCP Layer (Priority 3) ✅ COMPLETED
|
||||
- [x] ~~`tools.ts` - 90% coverage~~ ✅ 94.11%
|
||||
- [x] ~~`handlers-n8n-manager.ts` - 85% coverage~~ ✅ 92.71%
|
||||
- [x] ~~`handlers-workflow-diff.ts` - 85% coverage~~ ✅ 96.34%
|
||||
- [x] ~~`tools-documentation.ts` - 80% coverage~~ ✅ 94.12%
|
||||
|
||||
### Database Layer (Priority 4) ✅ COMPLETED
|
||||
- [x] ~~`node-repository.ts` - 85% coverage~~ ✅ 91.48%
|
||||
- [x] ~~`database-adapter.ts` - 85% coverage~~ ✅ 89.29%
|
||||
- [x] ~~`template-repository.ts` - 80% coverage~~ ✅ 86.78%
|
||||
|
||||
### Loaders and Mappers (Priority 5) ✅ COMPLETED
|
||||
- [x] ~~`node-loader.ts` - 85% coverage~~ ✅ 91.89%
|
||||
- [x] ~~`docs-mapper.ts` - 80% coverage~~ ✅ 95.45%
|
||||
|
||||
### Additional Critical Services Tested ✅ COMPLETED (Phase 3.5)
|
||||
- [x] ~~`n8n-api-client.ts`~~ ✅ 83.87%
|
||||
- [x] ~~`workflow-diff-engine.ts`~~ ✅ 90.06%
|
||||
- [x] ~~`n8n-validation.ts`~~ ✅ 97.14%
|
||||
- [x] ~~`node-specific-validators.ts`~~ ✅ 98.7%
|
||||
|
||||
## Week 5-6: Integration Tests 🚧 IN PROGRESS
|
||||
|
||||
### Real Status (July 29, 2025)
|
||||
**Context**: Building test suite from scratch on testing branch. Main branch has no tests.
|
||||
|
||||
**Overall Status**: 187/246 tests passing (76% pass rate)
|
||||
**Critical Issue**: CI shows green despite 58 failing tests due to `|| true` in workflow
|
||||
|
||||
### MCP Protocol Tests 🔄 MIXED STATUS
|
||||
- [x] ~~Full MCP server initialization~~ ✅ COMPLETED
|
||||
- [x] ~~Tool invocation flow~~ ✅ FIXED (30 tests in tool-invocation.test.ts)
|
||||
- [ ] Error handling and recovery ⚠️ 16 FAILING (error-handling.test.ts)
|
||||
- [x] ~~Concurrent request handling~~ ✅ COMPLETED
|
||||
- [ ] Session management ⚠️ 5 FAILING (timeout issues)
|
||||
|
||||
### n8n API Integration 🔄 PENDING
|
||||
- [ ] Workflow CRUD operations (MSW mocks ready)
|
||||
- [ ] Webhook triggering
|
||||
- [ ] Execution monitoring
|
||||
- [ ] Authentication handling
|
||||
- [ ] Error scenarios
|
||||
|
||||
### Database Integration ⚠️ ISSUES FOUND
|
||||
- [x] ~~SQLite operations with real DB~~ ✅ BASIC TESTS PASS
|
||||
- [ ] FTS5 search functionality ⚠️ 7 FAILING (syntax errors)
|
||||
- [ ] Transaction handling ⚠️ 1 FAILING (isolation issues)
|
||||
- [ ] Migration testing 🔄 NOT STARTED
|
||||
- [ ] Performance under load ⚠️ 4 FAILING (slower than thresholds)
|
||||
|
||||
## Week 7-8: E2E & Performance
|
||||
|
||||
### End-to-End Scenarios
|
||||
- [ ] Complete workflow creation flow
|
||||
- [ ] AI agent workflow setup
|
||||
- [ ] Template import and validation
|
||||
- [ ] Workflow execution monitoring
|
||||
- [ ] Error recovery scenarios
|
||||
|
||||
### Performance Benchmarks
|
||||
- [ ] Node loading speed (< 50ms per node)
|
||||
- [ ] Search performance (< 100ms for 1000 nodes)
|
||||
- [ ] Validation speed (< 10ms simple, < 100ms complex)
|
||||
- [ ] Database query performance
|
||||
- [ ] Memory usage profiling
|
||||
- [ ] Concurrent request handling
|
||||
|
||||
### Load Testing
|
||||
- [ ] 100 concurrent MCP requests
|
||||
- [ ] 10,000 nodes in database
|
||||
- [ ] 1,000 workflow validations/minute
|
||||
- [ ] Memory leak detection
|
||||
- [ ] Resource cleanup verification
|
||||
|
||||
## Testing Quality Gates
|
||||
|
||||
### Coverage Requirements
|
||||
- [ ] Overall: 80%+ (Currently: 62.67%)
|
||||
- [x] ~~Core services: 90%+~~ ✅ COMPLETED
|
||||
- [x] ~~MCP tools: 90%+~~ ✅ COMPLETED
|
||||
- [x] ~~Critical paths: 95%+~~ ✅ COMPLETED
|
||||
- [x] ~~New code: 90%+~~ ✅ COMPLETED
|
||||
|
||||
### Performance Requirements
|
||||
- [x] ~~All unit tests < 10ms~~ ✅ COMPLETED
|
||||
- [ ] Integration tests < 1s
|
||||
- [ ] E2E tests < 10s
|
||||
- [x] ~~Full suite < 5 minutes~~ ✅ COMPLETED (~2 minutes)
|
||||
- [x] ~~No memory leaks~~ ✅ COMPLETED
|
||||
|
||||
### Code Quality
|
||||
- [x] ~~No ESLint errors~~ ✅ COMPLETED
|
||||
- [x] ~~No TypeScript errors~~ ✅ COMPLETED
|
||||
- [x] ~~No console.log in tests~~ ✅ COMPLETED
|
||||
- [x] ~~All tests have descriptions~~ ✅ COMPLETED
|
||||
- [x] ~~No hardcoded values~~ ✅ COMPLETED
|
||||
|
||||
## Monitoring & Maintenance
|
||||
|
||||
### Daily
|
||||
- [ ] Check CI pipeline status
|
||||
- [ ] Review failed tests
|
||||
- [ ] Monitor flaky tests
|
||||
|
||||
### Weekly
|
||||
- [ ] Review coverage reports
|
||||
- [ ] Update test documentation
|
||||
- [ ] Performance benchmark review
|
||||
- [ ] Team sync on testing progress
|
||||
|
||||
### Monthly
|
||||
- [ ] Update baseline benchmarks
|
||||
- [ ] Review and refactor tests
|
||||
- [ ] Update testing strategy
|
||||
- [ ] Training/knowledge sharing
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
### Technical Risks
|
||||
- [ ] Mock complexity - Use simple, maintainable mocks
|
||||
- [ ] Test brittleness - Focus on behavior, not implementation
|
||||
- [ ] Performance impact - Run heavy tests in parallel
|
||||
- [ ] Flaky tests - Proper async handling and isolation
|
||||
|
||||
### Process Risks
|
||||
- [ ] Slow adoption - Provide training and examples
|
||||
- [ ] Coverage gaming - Review test quality, not just numbers
|
||||
- [ ] Maintenance burden - Automate what's possible
|
||||
- [ ] Integration complexity - Use test containers
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Current Reality Check
|
||||
- **Unit Tests**: ✅ SOLID (932 passing, 87.8% coverage)
|
||||
- **Integration Tests**: ⚠️ NEEDS WORK (58 failing, 76% pass rate)
|
||||
- **E2E Tests**: 🔄 NOT STARTED
|
||||
- **CI/CD**: ⚠️ BROKEN (hiding failures with || true)
|
||||
|
||||
### Revised Technical Metrics
|
||||
- Coverage: Currently 87.8% for unit tests ✅
|
||||
- Integration test pass rate: Target 100% (currently 76%)
|
||||
- Performance: Adjust thresholds based on reality
|
||||
- Reliability: Fix flaky tests during repair
|
||||
- Speed: CI pipeline < 5 minutes ✅ (~2 minutes)
|
||||
|
||||
### Team Metrics
|
||||
- All developers writing tests ✅
|
||||
- Tests reviewed in PRs ✅
|
||||
- No production bugs from tested code
|
||||
- Improved development velocity ✅
|
||||
|
||||
## Phases Completed
|
||||
|
||||
- **Phase 0**: Immediate Fixes ✅ COMPLETED
|
||||
- **Phase 1**: Vitest Migration ✅ COMPLETED
|
||||
- **Phase 2**: Test Infrastructure ✅ COMPLETED
|
||||
- **Phase 3**: Unit Tests (All 943 tests) ✅ COMPLETED
|
||||
- **Phase 3.5**: Critical Service Testing ✅ COMPLETED
|
||||
- **Phase 3.8**: CI/CD & Infrastructure ✅ COMPLETED
|
||||
- **Phase 4**: Integration Tests 🚧 IN PROGRESS
|
||||
- **Status**: 58 out of 246 tests failing (23.6% failure rate)
|
||||
- **CI Issue**: Tests appear green due to `|| true` error suppression
|
||||
- **Categories of Failures**:
|
||||
- Database: 9 tests (state isolation, FTS5 syntax)
|
||||
- MCP Protocol: 16 tests (response structure in error-handling.test.ts)
|
||||
- MSW: 6 tests (not initialized properly)
|
||||
- FTS5 Search: 7 tests (query syntax issues)
|
||||
- Session Management: 5 tests (async cleanup)
|
||||
- Performance: 15 tests (threshold mismatches)
|
||||
- **Next Steps**:
|
||||
1. Get team buy-in for "red" CI
|
||||
2. Remove `|| true` from workflow
|
||||
3. Fix tests systematically by category
|
||||
- **Phase 5**: E2E Tests 🔄 PENDING
|
||||
|
||||
## Resources & Tools
|
||||
|
||||
### Documentation
|
||||
- Vitest: https://vitest.dev/
|
||||
- Testing Library: https://testing-library.com/
|
||||
- MSW: https://mswjs.io/
|
||||
- Testcontainers: https://www.testcontainers.com/
|
||||
|
||||
### Monitoring
|
||||
- Codecov: https://codecov.io/
|
||||
- GitHub Actions: https://github.com/features/actions
|
||||
- Benchmark Action: https://github.com/benchmark-action/github-action-benchmark
|
||||
|
||||
### Team Resources
|
||||
- Testing best practices guide
|
||||
- Example test implementations
|
||||
- Mock usage patterns
|
||||
- Performance optimization tips
|
||||
@@ -1,472 +0,0 @@
|
||||
# n8n-MCP Testing Implementation Guide
|
||||
|
||||
## Phase 1: Foundation Setup (Week 1-2)
|
||||
|
||||
### 1.1 Install Vitest and Dependencies
|
||||
|
||||
```bash
|
||||
# Remove Jest
|
||||
npm uninstall jest ts-jest @types/jest
|
||||
|
||||
# Install Vitest and related packages
|
||||
npm install -D vitest @vitest/ui @vitest/coverage-v8
|
||||
npm install -D @testing-library/jest-dom
|
||||
npm install -D msw # For API mocking
|
||||
npm install -D @faker-js/faker # For test data
|
||||
npm install -D fishery # For factories
|
||||
```
|
||||
|
||||
### 1.2 Update package.json Scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
// Testing
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"test:e2e": "vitest run tests/e2e",
|
||||
"test:watch": "vitest watch",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:coverage:check": "vitest run --coverage --coverage.thresholdAutoUpdate=false",
|
||||
|
||||
// Benchmarks
|
||||
"bench": "vitest bench",
|
||||
"bench:compare": "vitest bench --compare",
|
||||
|
||||
// CI specific
|
||||
"test:ci": "vitest run --reporter=junit --reporter=default",
|
||||
"test:ci:coverage": "vitest run --coverage --reporter=junit --reporter=default"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 Migrate Existing Tests
|
||||
|
||||
```typescript
|
||||
// Before (Jest)
|
||||
import { describe, test, expect } from '@jest/globals';
|
||||
|
||||
// After (Vitest)
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Update mock syntax
|
||||
// Jest: jest.mock('module')
|
||||
// Vitest: vi.mock('module')
|
||||
|
||||
// Update timer mocks
|
||||
// Jest: jest.useFakeTimers()
|
||||
// Vitest: vi.useFakeTimers()
|
||||
```
|
||||
|
||||
### 1.4 Create Test Database Setup
|
||||
|
||||
```typescript
|
||||
// tests/setup/test-database.ts
|
||||
import Database from 'better-sqlite3';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
export class TestDatabase {
|
||||
private db: Database.Database;
|
||||
|
||||
constructor() {
|
||||
this.db = new Database(':memory:');
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
const schema = readFileSync(
|
||||
join(__dirname, '../../src/database/schema.sql'),
|
||||
'utf8'
|
||||
);
|
||||
this.db.exec(schema);
|
||||
}
|
||||
|
||||
seedNodes(nodes: any[]) {
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT INTO nodes (type, displayName, name, group, version, description, properties)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const insertMany = this.db.transaction((nodes) => {
|
||||
for (const node of nodes) {
|
||||
stmt.run(
|
||||
node.type,
|
||||
node.displayName,
|
||||
node.name,
|
||||
node.group,
|
||||
node.version,
|
||||
node.description,
|
||||
JSON.stringify(node.properties)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(nodes);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
getDb() {
|
||||
return this.db;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 2: Core Unit Tests (Week 3-4)
|
||||
|
||||
### 2.1 Test Organization Template
|
||||
|
||||
```typescript
|
||||
// tests/unit/services/[service-name].test.ts
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { ServiceName } from '@/services/service-name';
|
||||
|
||||
describe('ServiceName', () => {
|
||||
let service: ServiceName;
|
||||
let mockDependency: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup mocks
|
||||
mockDependency = {
|
||||
method: vi.fn()
|
||||
};
|
||||
|
||||
// Create service instance
|
||||
service = new ServiceName(mockDependency);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('methodName', () => {
|
||||
it('should handle happy path', async () => {
|
||||
// Arrange
|
||||
const input = { /* test data */ };
|
||||
mockDependency.method.mockResolvedValue({ /* mock response */ });
|
||||
|
||||
// Act
|
||||
const result = await service.methodName(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(/* expected output */);
|
||||
expect(mockDependency.method).toHaveBeenCalledWith(/* expected args */);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
// Arrange
|
||||
mockDependency.method.mockRejectedValue(new Error('Test error'));
|
||||
|
||||
// Act & Assert
|
||||
await expect(service.methodName({})).rejects.toThrow('Expected error message');
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2.2 Mock Strategies by Layer
|
||||
|
||||
#### Database Layer
|
||||
```typescript
|
||||
// tests/unit/database/node-repository.test.ts
|
||||
import { vi } from 'vitest';
|
||||
|
||||
vi.mock('better-sqlite3', () => ({
|
||||
default: vi.fn(() => ({
|
||||
prepare: vi.fn(() => ({
|
||||
all: vi.fn(() => mockData),
|
||||
get: vi.fn((id) => mockData.find(d => d.id === id)),
|
||||
run: vi.fn(() => ({ changes: 1 }))
|
||||
})),
|
||||
exec: vi.fn(),
|
||||
close: vi.fn()
|
||||
}))
|
||||
}));
|
||||
```
|
||||
|
||||
#### External APIs
|
||||
```typescript
|
||||
// tests/unit/services/__mocks__/axios.ts
|
||||
export default {
|
||||
create: vi.fn(() => ({
|
||||
get: vi.fn(() => Promise.resolve({ data: {} })),
|
||||
post: vi.fn(() => Promise.resolve({ data: { id: '123' } })),
|
||||
put: vi.fn(() => Promise.resolve({ data: {} })),
|
||||
delete: vi.fn(() => Promise.resolve({ data: {} }))
|
||||
}))
|
||||
};
|
||||
```
|
||||
|
||||
#### File System
|
||||
```typescript
|
||||
// Use memfs for file system mocking
|
||||
import { vol } from 'memfs';
|
||||
|
||||
vi.mock('fs', () => vol);
|
||||
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
vol.fromJSON({
|
||||
'/test/file.json': JSON.stringify({ test: 'data' })
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 2.3 Critical Path Tests
|
||||
|
||||
```typescript
|
||||
// Priority 1: Node Loading and Parsing
|
||||
// tests/unit/loaders/node-loader.test.ts
|
||||
|
||||
// Priority 2: Configuration Validation
|
||||
// tests/unit/services/config-validator.test.ts
|
||||
|
||||
// Priority 3: MCP Tools
|
||||
// tests/unit/mcp/tools.test.ts
|
||||
|
||||
// Priority 4: Database Operations
|
||||
// tests/unit/database/node-repository.test.ts
|
||||
|
||||
// Priority 5: Workflow Validation
|
||||
// tests/unit/services/workflow-validator.test.ts
|
||||
```
|
||||
|
||||
## Phase 3: Integration Tests (Week 5-6)
|
||||
|
||||
### 3.1 Test Container Setup
|
||||
|
||||
```typescript
|
||||
// tests/setup/test-containers.ts
|
||||
import { GenericContainer, StartedTestContainer } from 'testcontainers';
|
||||
|
||||
export class N8nTestContainer {
|
||||
private container: StartedTestContainer;
|
||||
|
||||
async start() {
|
||||
this.container = await new GenericContainer('n8nio/n8n:latest')
|
||||
.withExposedPorts(5678)
|
||||
.withEnv('N8N_BASIC_AUTH_ACTIVE', 'false')
|
||||
.withEnv('N8N_ENCRYPTION_KEY', 'test-key')
|
||||
.start();
|
||||
|
||||
return {
|
||||
url: `http://localhost:${this.container.getMappedPort(5678)}`,
|
||||
stop: () => this.container.stop()
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Integration Test Pattern
|
||||
|
||||
```typescript
|
||||
// tests/integration/n8n-api/workflow-crud.test.ts
|
||||
import { N8nTestContainer } from '@tests/setup/test-containers';
|
||||
import { N8nAPIClient } from '@/services/n8n-api-client';
|
||||
|
||||
describe('n8n API Integration', () => {
|
||||
let container: any;
|
||||
let apiClient: N8nAPIClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
container = await new N8nTestContainer().start();
|
||||
apiClient = new N8nAPIClient(container.url);
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
await container.stop();
|
||||
});
|
||||
|
||||
it('should create and retrieve workflow', async () => {
|
||||
// Create workflow
|
||||
const workflow = createTestWorkflow();
|
||||
const created = await apiClient.createWorkflow(workflow);
|
||||
|
||||
expect(created.id).toBeDefined();
|
||||
|
||||
// Retrieve workflow
|
||||
const retrieved = await apiClient.getWorkflow(created.id);
|
||||
expect(retrieved.name).toBe(workflow.name);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Phase 4: E2E & Performance (Week 7-8)
|
||||
|
||||
### 4.1 E2E Test Setup
|
||||
|
||||
```typescript
|
||||
// tests/e2e/workflows/complete-workflow.test.ts
|
||||
import { MCPClient } from '@tests/utils/mcp-client';
|
||||
import { N8nTestContainer } from '@tests/setup/test-containers';
|
||||
|
||||
describe('Complete Workflow E2E', () => {
|
||||
let mcpServer: any;
|
||||
let n8nContainer: any;
|
||||
let mcpClient: MCPClient;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Start n8n
|
||||
n8nContainer = await new N8nTestContainer().start();
|
||||
|
||||
// Start MCP server
|
||||
mcpServer = await startMCPServer({
|
||||
n8nUrl: n8nContainer.url
|
||||
});
|
||||
|
||||
// Create MCP client
|
||||
mcpClient = new MCPClient(mcpServer.url);
|
||||
}, 60000);
|
||||
|
||||
it('should execute complete workflow creation flow', async () => {
|
||||
// 1. Search for nodes
|
||||
const searchResult = await mcpClient.call('search_nodes', {
|
||||
query: 'webhook http slack'
|
||||
});
|
||||
|
||||
// 2. Get node details
|
||||
const webhookInfo = await mcpClient.call('get_node_info', {
|
||||
nodeType: 'nodes-base.webhook'
|
||||
});
|
||||
|
||||
// 3. Create workflow
|
||||
const workflow = new WorkflowBuilder('E2E Test')
|
||||
.addWebhookNode()
|
||||
.addHttpRequestNode()
|
||||
.addSlackNode()
|
||||
.connectSequentially()
|
||||
.build();
|
||||
|
||||
// 4. Validate workflow
|
||||
const validation = await mcpClient.call('validate_workflow', {
|
||||
workflow
|
||||
});
|
||||
|
||||
expect(validation.isValid).toBe(true);
|
||||
|
||||
// 5. Deploy to n8n
|
||||
const deployed = await mcpClient.call('n8n_create_workflow', {
|
||||
...workflow
|
||||
});
|
||||
|
||||
expect(deployed.id).toBeDefined();
|
||||
expect(deployed.active).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 4.2 Performance Benchmarks
|
||||
|
||||
```typescript
|
||||
// vitest.benchmark.config.ts
|
||||
export default {
|
||||
test: {
|
||||
benchmark: {
|
||||
// Output benchmark results
|
||||
outputFile: './benchmark-results.json',
|
||||
|
||||
// Compare with baseline
|
||||
compare: './benchmark-baseline.json',
|
||||
|
||||
// Fail if performance degrades by more than 10%
|
||||
threshold: {
|
||||
p95: 1.1, // 110% of baseline
|
||||
p99: 1.2 // 120% of baseline
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
### 1. Test Naming Convention
|
||||
```typescript
|
||||
// Format: should [expected behavior] when [condition]
|
||||
it('should return user data when valid ID is provided')
|
||||
it('should throw ValidationError when email is invalid')
|
||||
it('should retry 3 times when network fails')
|
||||
```
|
||||
|
||||
### 2. Test Data Builders
|
||||
```typescript
|
||||
// Use builders for complex test data
|
||||
const user = new UserBuilder()
|
||||
.withEmail('test@example.com')
|
||||
.withRole('admin')
|
||||
.build();
|
||||
```
|
||||
|
||||
### 3. Custom Matchers
|
||||
```typescript
|
||||
// tests/utils/matchers.ts
|
||||
export const toBeValidNode = (received: any) => {
|
||||
const pass =
|
||||
received.type &&
|
||||
received.displayName &&
|
||||
received.properties &&
|
||||
Array.isArray(received.properties);
|
||||
|
||||
return {
|
||||
pass,
|
||||
message: () => `expected ${received} to be a valid node`
|
||||
};
|
||||
};
|
||||
|
||||
// Usage
|
||||
expect(node).toBeValidNode();
|
||||
```
|
||||
|
||||
### 4. Snapshot Testing
|
||||
```typescript
|
||||
// For complex structures
|
||||
it('should generate correct node schema', () => {
|
||||
const schema = generateNodeSchema(node);
|
||||
expect(schema).toMatchSnapshot();
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Test Isolation
|
||||
```typescript
|
||||
// Always clean up after tests
|
||||
afterEach(async () => {
|
||||
await cleanup();
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
```
|
||||
|
||||
## Coverage Goals by Module
|
||||
|
||||
| Module | Target | Priority | Notes |
|
||||
|--------|--------|----------|-------|
|
||||
| services/config-validator | 95% | High | Critical for reliability |
|
||||
| services/workflow-validator | 90% | High | Core functionality |
|
||||
| mcp/tools | 90% | High | User-facing API |
|
||||
| database/node-repository | 85% | Medium | Well-tested DB layer |
|
||||
| loaders/node-loader | 85% | Medium | External dependencies |
|
||||
| parsers/* | 90% | High | Data transformation |
|
||||
| utils/* | 80% | Low | Helper functions |
|
||||
| scripts/* | 50% | Low | One-time scripts |
|
||||
|
||||
## Continuous Improvement
|
||||
|
||||
1. **Weekly Reviews**: Review test coverage and identify gaps
|
||||
2. **Performance Baselines**: Update benchmarks monthly
|
||||
3. **Flaky Test Detection**: Monitor and fix within 48 hours
|
||||
4. **Test Documentation**: Keep examples updated
|
||||
5. **Developer Training**: Pair programming on tests
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- [ ] All tests pass in CI (0 failures)
|
||||
- [ ] Coverage > 80% overall
|
||||
- [ ] No flaky tests
|
||||
- [ ] CI runs < 5 minutes
|
||||
- [ ] Performance benchmarks stable
|
||||
- [ ] Zero production bugs from tested code
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,66 +0,0 @@
|
||||
# Token Efficiency Improvements Summary
|
||||
|
||||
## Overview
|
||||
Made all MCP tool descriptions concise and token-efficient while preserving essential information.
|
||||
|
||||
## Key Improvements
|
||||
|
||||
### Before vs After Examples
|
||||
|
||||
1. **search_nodes**
|
||||
- Before: ~350 chars with verbose explanation
|
||||
- After: 165 chars
|
||||
- `Search nodes by keywords. Modes: OR (any word), AND (all words), FUZZY (typos OK). Primary nodes ranked first. Examples: "webhook"→Webhook, "http call"→HTTP Request.`
|
||||
|
||||
2. **get_node_info**
|
||||
- Before: ~450 chars with warnings about size
|
||||
- After: 174 chars
|
||||
- `Get FULL node schema (100KB+). TIP: Use get_node_essentials first! Returns all properties/operations/credentials. Prefix required: "nodes-base.httpRequest" not "httpRequest".`
|
||||
|
||||
3. **validate_node_minimal**
|
||||
- Before: ~350 chars explaining what it doesn't do
|
||||
- After: 102 chars
|
||||
- `Fast check for missing required fields only. No warnings/suggestions. Returns: list of missing fields.`
|
||||
|
||||
4. **get_property_dependencies**
|
||||
- Before: ~400 chars with full example
|
||||
- After: 131 chars
|
||||
- `Shows property dependencies and visibility rules. Example: sendBody=true reveals body fields. Test visibility with optional config.`
|
||||
|
||||
## Statistics
|
||||
|
||||
### Documentation Tools (22 tools)
|
||||
- Average description length: **129 characters**
|
||||
- Total characters: 2,836
|
||||
- Tools over 200 chars: 1 (list_nodes at 204)
|
||||
|
||||
### Management Tools (17 tools)
|
||||
- Average description length: **93 characters**
|
||||
- Total characters: 1,578
|
||||
- Tools over 200 chars: 1 (n8n_update_partial_workflow at 284)
|
||||
|
||||
## Strategy Used
|
||||
|
||||
1. **Remove redundancy**: Eliminated repeated information available in parameter descriptions
|
||||
2. **Use abbreviations**: "vs" instead of "versus", "&" instead of "and" where appropriate
|
||||
3. **Compact examples**: `"webhook"→Webhook` instead of verbose explanations
|
||||
4. **Direct language**: "Fast check" instead of "Quick validation that only checks"
|
||||
5. **Move details to documentation**: Complex tools reference `tools_documentation()` for full details
|
||||
6. **Essential info only**: Focus on what the tool does, not how it works internally
|
||||
|
||||
## Special Cases
|
||||
|
||||
### n8n_update_partial_workflow
|
||||
This tool's description is necessarily longer (284 chars) because:
|
||||
- Lists all 13 operation types
|
||||
- Critical for users to know available operations
|
||||
- Directs to full documentation for details
|
||||
|
||||
### Complex Documentation Preserved
|
||||
For tools like `n8n_update_partial_workflow`, detailed documentation was moved to `tools-documentation.ts` rather than deleted, ensuring users can still access comprehensive information when needed.
|
||||
|
||||
## Impact
|
||||
- **Token savings**: ~65-70% reduction in description tokens
|
||||
- **Faster AI responses**: Less context used for tool descriptions
|
||||
- **Better UX**: Clearer, more scannable tool list
|
||||
- **Maintained functionality**: All essential information preserved
|
||||
@@ -1,118 +0,0 @@
|
||||
# Transactional Updates Example
|
||||
|
||||
This example demonstrates the new transactional update capabilities in v2.7.0.
|
||||
|
||||
## Before (v2.6.x and earlier)
|
||||
|
||||
Previously, you had to carefully order operations to ensure nodes existed before connecting them:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "workflow-123",
|
||||
"operations": [
|
||||
// 1. First add all nodes
|
||||
{ "type": "addNode", "node": { "name": "Process", "type": "n8n-nodes-base.set", ... }},
|
||||
{ "type": "addNode", "node": { "name": "Notify", "type": "n8n-nodes-base.slack", ... }},
|
||||
|
||||
// 2. Then add connections (would fail if done before nodes)
|
||||
{ "type": "addConnection", "source": "Webhook", "target": "Process" },
|
||||
{ "type": "addConnection", "source": "Process", "target": "Notify" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## After (v2.7.0+)
|
||||
|
||||
Now you can write operations in any order - the engine automatically handles dependencies:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "workflow-123",
|
||||
"operations": [
|
||||
// Connections can come first!
|
||||
{ "type": "addConnection", "source": "Webhook", "target": "Process" },
|
||||
{ "type": "addConnection", "source": "Process", "target": "Notify" },
|
||||
|
||||
// Nodes added later - still works!
|
||||
{ "type": "addNode", "node": { "name": "Process", "type": "n8n-nodes-base.set", "position": [400, 300] }},
|
||||
{ "type": "addNode", "node": { "name": "Notify", "type": "n8n-nodes-base.slack", "position": [600, 300] }}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Two-Pass Processing**:
|
||||
- Pass 1: All node operations (add, remove, update, move, enable, disable)
|
||||
- Pass 2: All other operations (connections, settings, metadata)
|
||||
|
||||
2. **Operation Limit**: Maximum 5 operations per request keeps complexity manageable
|
||||
|
||||
3. **Atomic Updates**: All operations succeed or all fail - no partial updates
|
||||
|
||||
## Benefits for AI Agents
|
||||
|
||||
- **Intuitive**: Write operations in the order that makes sense logically
|
||||
- **Reliable**: No need to track dependencies manually
|
||||
- **Simple**: Focus on what to change, not how to order changes
|
||||
- **Safe**: Built-in limits prevent overly complex operations
|
||||
|
||||
## Complete Example
|
||||
|
||||
Here's a real-world example of adding error handling to a workflow:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "workflow-123",
|
||||
"operations": [
|
||||
// Define the flow first (makes logical sense)
|
||||
{
|
||||
"type": "removeConnection",
|
||||
"source": "HTTP Request",
|
||||
"target": "Save to DB"
|
||||
},
|
||||
{
|
||||
"type": "addConnection",
|
||||
"source": "HTTP Request",
|
||||
"target": "Error Handler"
|
||||
},
|
||||
{
|
||||
"type": "addConnection",
|
||||
"source": "Error Handler",
|
||||
"target": "Send Alert"
|
||||
},
|
||||
|
||||
// Then add the nodes
|
||||
{
|
||||
"type": "addNode",
|
||||
"node": {
|
||||
"name": "Error Handler",
|
||||
"type": "n8n-nodes-base.if",
|
||||
"position": [500, 400],
|
||||
"parameters": {
|
||||
"conditions": {
|
||||
"boolean": [{
|
||||
"value1": "={{$json.error}}",
|
||||
"value2": true
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "addNode",
|
||||
"node": {
|
||||
"name": "Send Alert",
|
||||
"type": "n8n-nodes-base.emailSend",
|
||||
"position": [700, 400],
|
||||
"parameters": {
|
||||
"to": "alerts@company.com",
|
||||
"subject": "Workflow Error Alert"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
All operations will be processed correctly, even though connections reference nodes that don't exist yet!
|
||||
@@ -1,92 +0,0 @@
|
||||
# Validation Improvements v2.4.2
|
||||
|
||||
Based on AI agent feedback, we've implemented several improvements to the `validate_node_operation` tool:
|
||||
|
||||
## 🎯 Issues Addressed
|
||||
|
||||
### 1. **@version Warnings** ✅ FIXED
|
||||
- **Issue**: Showed confusing warnings about `@version` property not being used
|
||||
- **Fix**: Filter out internal properties starting with `@` or `_`
|
||||
- **Result**: No more false warnings about internal n8n properties
|
||||
|
||||
### 2. **Duplicate Errors** ✅ FIXED
|
||||
- **Issue**: Same error shown multiple times (e.g., missing `ts` field)
|
||||
- **Fix**: Implemented deduplication that keeps the most specific error message
|
||||
- **Result**: Each error shown only once with the best description
|
||||
|
||||
### 3. **Basic Code Validation** ✅ ADDED
|
||||
- **Issue**: No syntax validation for Code node
|
||||
- **Fix**: Added basic syntax checks for JavaScript and Python
|
||||
- **Features**:
|
||||
- Unbalanced braces/parentheses detection
|
||||
- Python indentation consistency check
|
||||
- n8n-specific patterns (return statement, input access)
|
||||
- Security warnings (eval/exec usage)
|
||||
|
||||
## 📊 Before & After
|
||||
|
||||
### Before (v2.4.1):
|
||||
```json
|
||||
{
|
||||
"errors": [
|
||||
{ "property": "ts", "message": "Required property 'Message Timestamp' is missing" },
|
||||
{ "property": "ts", "message": "Message timestamp (ts) is required to update a message" }
|
||||
],
|
||||
"warnings": [
|
||||
{ "property": "@version", "message": "Property '@version' is configured but won't be used" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### After (v2.4.2):
|
||||
```json
|
||||
{
|
||||
"errors": [
|
||||
{ "property": "ts", "message": "Message timestamp (ts) is required to update a message",
|
||||
"fix": "Provide the timestamp of the message to update" }
|
||||
],
|
||||
"warnings": [] // No @version warning
|
||||
}
|
||||
```
|
||||
|
||||
## 🆕 Code Validation Examples
|
||||
|
||||
### JavaScript Syntax Check:
|
||||
```javascript
|
||||
// Missing closing brace
|
||||
if (true) {
|
||||
return items;
|
||||
// Error: "Unbalanced braces detected"
|
||||
```
|
||||
|
||||
### Python Indentation Check:
|
||||
```python
|
||||
def process():
|
||||
if True: # Tab
|
||||
return items # Spaces
|
||||
# Error: "Mixed tabs and spaces in indentation"
|
||||
```
|
||||
|
||||
### n8n Pattern Check:
|
||||
```javascript
|
||||
const result = items.map(item => item.json);
|
||||
// Warning: "No return statement found"
|
||||
// Suggestion: "Add: return items;"
|
||||
```
|
||||
|
||||
## 🚀 Impact
|
||||
|
||||
- **Cleaner validation results** - No more noise from internal properties
|
||||
- **Clearer error messages** - Each issue reported once with best description
|
||||
- **Better code quality** - Basic syntax validation catches common mistakes
|
||||
- **n8n best practices** - Warns about missing return statements and input handling
|
||||
|
||||
## 📝 Summary
|
||||
|
||||
The `validate_node_operation` tool is now even more helpful for AI agents and developers:
|
||||
- 95% reduction in false positives (operation-aware)
|
||||
- No duplicate or confusing warnings
|
||||
- Basic code validation for common syntax errors
|
||||
- n8n-specific pattern checking
|
||||
|
||||
**Rating improved from 9/10 to 9.5/10!** 🎉
|
||||
0
n8n-nodes.db
Normal file
0
n8n-nodes.db
Normal file
826
package-lock.json
generated
826
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.17.0",
|
||||
"version": "2.18.0",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
@@ -132,15 +132,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.13.2",
|
||||
"@n8n/n8n-nodes-langchain": "^1.112.2",
|
||||
"@n8n/n8n-nodes-langchain": "^1.113.1",
|
||||
"@supabase/supabase-js": "^2.57.4",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"lru-cache": "^11.2.1",
|
||||
"n8n": "^1.113.3",
|
||||
"n8n-core": "^1.112.1",
|
||||
"n8n-workflow": "^1.110.0",
|
||||
"n8n": "^1.114.3",
|
||||
"n8n-core": "^1.113.1",
|
||||
"n8n-workflow": "^1.111.0",
|
||||
"openai": "^4.77.0",
|
||||
"sql.js": "^1.13.0",
|
||||
"uuid": "^10.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp-runtime",
|
||||
"version": "2.16.3",
|
||||
"version": "2.17.6",
|
||||
"description": "n8n MCP Server Runtime Dependencies Only",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# n8n-MCP v2.7.0 Release Notes
|
||||
|
||||
## 🎉 What's New
|
||||
|
||||
### 🔧 File Refactoring & Version Management
|
||||
- **Renamed core MCP files** to remove unnecessary suffixes for cleaner codebase:
|
||||
- `tools-update.ts` → `tools.ts`
|
||||
- `server-update.ts` → `server.ts`
|
||||
- `http-server-fixed.ts` → `http-server.ts`
|
||||
- **Fixed version management** - Now reads from package.json as single source of truth (fixes #5)
|
||||
- **Updated imports** across 21+ files to use the new file names
|
||||
|
||||
### 🔍 New Diagnostic Tool
|
||||
- **Added `n8n_diagnostic` tool** - Helps troubleshoot why n8n management tools might not be appearing
|
||||
- Shows environment variable status, API connectivity, and tool availability
|
||||
- Provides step-by-step troubleshooting guidance
|
||||
- Includes verbose mode for additional debug information
|
||||
|
||||
### 🧹 Code Cleanup
|
||||
- Removed legacy HTTP server implementation with known issues
|
||||
- Removed unused legacy API client
|
||||
- Added version utility for consistent version handling
|
||||
- Added script to sync runtime package version
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Docker (Recommended)
|
||||
```bash
|
||||
docker pull ghcr.io/czlonkowski/n8n-mcp:2.7.0
|
||||
```
|
||||
|
||||
### Claude Desktop
|
||||
Update your configuration to use the latest version:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"n8n-mcp": {
|
||||
"command": "docker",
|
||||
"args": ["run", "-i", "--rm", "ghcr.io/czlonkowski/n8n-mcp:2.7.0"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
- Fixed version mismatch where version was hardcoded as 2.4.1 instead of reading from package.json
|
||||
- Improved error messages for better debugging
|
||||
|
||||
## 📚 Documentation Updates
|
||||
- Condensed version history in CLAUDE.md
|
||||
- Updated documentation structure in README.md
|
||||
- Removed outdated documentation files
|
||||
- Added n8n_diagnostic tool to documentation
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
Thanks to all contributors and users who reported issues!
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: https://github.com/czlonkowski/n8n-mcp/blob/main/CHANGELOG.md
|
||||
@@ -62,8 +62,12 @@ export class MCPEngine {
|
||||
hiddenProperties: []
|
||||
};
|
||||
}
|
||||
|
||||
return ConfigValidator.validate(args.nodeType, args.config, node.properties || []);
|
||||
|
||||
// CRITICAL FIX: Extract user-provided keys before validation
|
||||
// This prevents false warnings about default values
|
||||
const userProvidedKeys = new Set(Object.keys(args.config || {}));
|
||||
|
||||
return ConfigValidator.validate(args.nodeType, args.config, node.properties || [], userProvidedKeys);
|
||||
}
|
||||
|
||||
async validateNodeMinimal(args: any) {
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { PropertyExtractor } from './property-extractor';
|
||||
import type {
|
||||
NodeClass,
|
||||
VersionedNodeInstance
|
||||
} from '../types/node-types';
|
||||
import {
|
||||
isVersionedNodeInstance,
|
||||
isVersionedNodeClass,
|
||||
getNodeDescription as getNodeDescriptionHelper
|
||||
} from '../types/node-types';
|
||||
import type { INodeTypeBaseDescription, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export interface ParsedNode {
|
||||
style: 'declarative' | 'programmatic';
|
||||
@@ -22,9 +32,9 @@ export interface ParsedNode {
|
||||
|
||||
export class NodeParser {
|
||||
private propertyExtractor = new PropertyExtractor();
|
||||
private currentNodeClass: any = null;
|
||||
|
||||
parse(nodeClass: any, packageName: string): ParsedNode {
|
||||
private currentNodeClass: NodeClass | null = null;
|
||||
|
||||
parse(nodeClass: NodeClass, packageName: string): ParsedNode {
|
||||
this.currentNodeClass = nodeClass;
|
||||
// Get base description (handles versioned nodes)
|
||||
const description = this.getNodeDescription(nodeClass);
|
||||
@@ -50,46 +60,64 @@ export class NodeParser {
|
||||
};
|
||||
}
|
||||
|
||||
private getNodeDescription(nodeClass: any): any {
|
||||
private getNodeDescription(nodeClass: NodeClass): INodeTypeBaseDescription | INodeTypeDescription {
|
||||
// Try to get description from the class first
|
||||
let description: any;
|
||||
|
||||
// Check if it's a versioned node (has baseDescription and nodeVersions)
|
||||
if (typeof nodeClass === 'function' && nodeClass.prototype &&
|
||||
nodeClass.prototype.constructor &&
|
||||
nodeClass.prototype.constructor.name === 'VersionedNodeType') {
|
||||
let description: INodeTypeBaseDescription | INodeTypeDescription | undefined;
|
||||
|
||||
// Check if it's a versioned node using type guard
|
||||
if (isVersionedNodeClass(nodeClass)) {
|
||||
// This is a VersionedNodeType class - instantiate it
|
||||
const instance = new nodeClass();
|
||||
description = instance.baseDescription || {};
|
||||
try {
|
||||
const instance = new (nodeClass as new () => VersionedNodeInstance)();
|
||||
// Strategic any assertion for accessing both description and baseDescription
|
||||
const inst = instance as any;
|
||||
// Try description first (real VersionedNodeType with getter)
|
||||
// Only fallback to baseDescription if nodeVersions exists (complete VersionedNodeType mock)
|
||||
// This prevents using baseDescription for incomplete mocks that test edge cases
|
||||
description = inst.description || (inst.nodeVersions ? inst.baseDescription : undefined);
|
||||
|
||||
// If still undefined (incomplete mock), leave as undefined to use catch block fallback
|
||||
} catch (e) {
|
||||
// Some nodes might require parameters to instantiate
|
||||
}
|
||||
} else if (typeof nodeClass === 'function') {
|
||||
// Try to instantiate to get description
|
||||
try {
|
||||
const instance = new nodeClass();
|
||||
description = instance.description || {};
|
||||
|
||||
// For versioned nodes, we might need to look deeper
|
||||
if (!description.name && instance.baseDescription) {
|
||||
description = instance.baseDescription;
|
||||
description = instance.description;
|
||||
// If description is empty or missing name, check for baseDescription fallback
|
||||
if (!description || !description.name) {
|
||||
const inst = instance as any;
|
||||
if (inst.baseDescription?.name) {
|
||||
description = inst.baseDescription;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Some nodes might require parameters to instantiate
|
||||
// Try to access static properties
|
||||
description = nodeClass.description || {};
|
||||
description = (nodeClass as any).description;
|
||||
}
|
||||
} else {
|
||||
// Maybe it's already an instance
|
||||
description = nodeClass.description || {};
|
||||
description = nodeClass.description;
|
||||
// If description is empty or missing name, check for baseDescription fallback
|
||||
if (!description || !description.name) {
|
||||
const inst = nodeClass as any;
|
||||
if (inst.baseDescription?.name) {
|
||||
description = inst.baseDescription;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return description;
|
||||
|
||||
return description || ({} as any);
|
||||
}
|
||||
|
||||
private detectStyle(nodeClass: any): 'declarative' | 'programmatic' {
|
||||
private detectStyle(nodeClass: NodeClass): 'declarative' | 'programmatic' {
|
||||
const desc = this.getNodeDescription(nodeClass);
|
||||
return desc.routing ? 'declarative' : 'programmatic';
|
||||
return (desc as any).routing ? 'declarative' : 'programmatic';
|
||||
}
|
||||
|
||||
private extractNodeType(description: any, packageName: string): string {
|
||||
|
||||
private extractNodeType(description: INodeTypeBaseDescription | INodeTypeDescription, packageName: string): string {
|
||||
// Ensure we have the full node type including package prefix
|
||||
const name = description.name;
|
||||
|
||||
@@ -106,57 +134,97 @@ export class NodeParser {
|
||||
return `${packagePrefix}.${name}`;
|
||||
}
|
||||
|
||||
private extractCategory(description: any): string {
|
||||
return description.group?.[0] ||
|
||||
description.categories?.[0] ||
|
||||
description.category ||
|
||||
private extractCategory(description: INodeTypeBaseDescription | INodeTypeDescription): string {
|
||||
return description.group?.[0] ||
|
||||
(description as any).categories?.[0] ||
|
||||
(description as any).category ||
|
||||
'misc';
|
||||
}
|
||||
|
||||
private detectTrigger(description: any): boolean {
|
||||
|
||||
private detectTrigger(description: INodeTypeBaseDescription | INodeTypeDescription): boolean {
|
||||
// Strategic any assertion for properties that only exist on INodeTypeDescription
|
||||
const desc = description as any;
|
||||
|
||||
// Primary check: group includes 'trigger'
|
||||
if (description.group && Array.isArray(description.group)) {
|
||||
if (description.group.includes('trigger')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Fallback checks for edge cases
|
||||
return description.polling === true ||
|
||||
description.trigger === true ||
|
||||
description.eventTrigger === true ||
|
||||
return desc.polling === true ||
|
||||
desc.trigger === true ||
|
||||
desc.eventTrigger === true ||
|
||||
description.name?.toLowerCase().includes('trigger');
|
||||
}
|
||||
|
||||
private detectWebhook(description: any): boolean {
|
||||
return (description.webhooks?.length > 0) ||
|
||||
description.webhook === true ||
|
||||
private detectWebhook(description: INodeTypeBaseDescription | INodeTypeDescription): boolean {
|
||||
const desc = description as any; // INodeTypeDescription has webhooks, but INodeTypeBaseDescription doesn't
|
||||
return (desc.webhooks?.length > 0) ||
|
||||
desc.webhook === true ||
|
||||
description.name?.toLowerCase().includes('webhook');
|
||||
}
|
||||
|
||||
private extractVersion(nodeClass: any): string {
|
||||
// Check instance for baseDescription first
|
||||
/**
|
||||
* Extracts the version from a node class.
|
||||
*
|
||||
* Priority Chain:
|
||||
* 1. Instance currentVersion (VersionedNodeType's computed property)
|
||||
* 2. Instance description.defaultVersion (explicit default)
|
||||
* 3. Instance nodeVersions (fallback to max available version)
|
||||
* 4. Description version array (legacy nodes)
|
||||
* 5. Description version scalar (simple versioning)
|
||||
* 6. Class-level properties (if instantiation fails)
|
||||
* 7. Default to "1"
|
||||
*
|
||||
* Critical Fix (v2.17.4): Removed check for non-existent instance.baseDescription.defaultVersion
|
||||
* which caused AI Agent to incorrectly return version "3" instead of "2.2"
|
||||
*
|
||||
* @param nodeClass - The node class or instance to extract version from
|
||||
* @returns The version as a string
|
||||
*/
|
||||
private extractVersion(nodeClass: NodeClass): string {
|
||||
// Check instance properties first
|
||||
try {
|
||||
const instance = typeof nodeClass === 'function' ? new nodeClass() : nodeClass;
|
||||
|
||||
// Handle instance-level baseDescription
|
||||
if (instance?.baseDescription?.defaultVersion) {
|
||||
return instance.baseDescription.defaultVersion.toString();
|
||||
// Strategic any assertion - instance could be INodeType or IVersionedNodeType
|
||||
const inst = instance as any;
|
||||
|
||||
// PRIORITY 1: Check currentVersion (what VersionedNodeType actually uses)
|
||||
// For VersionedNodeType, currentVersion = defaultVersion ?? max(nodeVersions)
|
||||
if (inst?.currentVersion !== undefined) {
|
||||
return inst.currentVersion.toString();
|
||||
}
|
||||
|
||||
// Handle instance-level nodeVersions
|
||||
if (instance?.nodeVersions) {
|
||||
const versions = Object.keys(instance.nodeVersions);
|
||||
return Math.max(...versions.map(Number)).toString();
|
||||
|
||||
// PRIORITY 2: Handle instance-level description.defaultVersion
|
||||
// VersionedNodeType stores baseDescription as 'description', not 'baseDescription'
|
||||
if (inst?.description?.defaultVersion) {
|
||||
return inst.description.defaultVersion.toString();
|
||||
}
|
||||
|
||||
|
||||
// PRIORITY 3: Handle instance-level nodeVersions (fallback to max)
|
||||
if (inst?.nodeVersions) {
|
||||
const versions = Object.keys(inst.nodeVersions).map(Number);
|
||||
if (versions.length > 0) {
|
||||
const maxVersion = Math.max(...versions);
|
||||
if (!isNaN(maxVersion)) {
|
||||
return maxVersion.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle version array in description (e.g., [1, 1.1, 1.2])
|
||||
if (instance?.description?.version) {
|
||||
const version = instance.description.version;
|
||||
if (inst?.description?.version) {
|
||||
const version = inst.description.version;
|
||||
if (Array.isArray(version)) {
|
||||
// Find the maximum version from the array
|
||||
const maxVersion = Math.max(...version.map((v: any) => parseFloat(v.toString())));
|
||||
return maxVersion.toString();
|
||||
const numericVersions = version.map((v: any) => parseFloat(v.toString()));
|
||||
if (numericVersions.length > 0) {
|
||||
const maxVersion = Math.max(...numericVersions);
|
||||
if (!isNaN(maxVersion)) {
|
||||
return maxVersion.toString();
|
||||
}
|
||||
}
|
||||
} else if (typeof version === 'number' || typeof version === 'string') {
|
||||
return version.toString();
|
||||
}
|
||||
@@ -165,94 +233,119 @@ export class NodeParser {
|
||||
// Some nodes might require parameters to instantiate
|
||||
// Try class-level properties
|
||||
}
|
||||
|
||||
|
||||
// Handle class-level VersionedNodeType with defaultVersion
|
||||
if (nodeClass.baseDescription?.defaultVersion) {
|
||||
return nodeClass.baseDescription.defaultVersion.toString();
|
||||
// Note: Most VersionedNodeType classes don't have static properties
|
||||
// Strategic any assertion for class-level property access
|
||||
const nodeClassAny = nodeClass as any;
|
||||
if (nodeClassAny.description?.defaultVersion) {
|
||||
return nodeClassAny.description.defaultVersion.toString();
|
||||
}
|
||||
|
||||
|
||||
// Handle class-level VersionedNodeType with nodeVersions
|
||||
if (nodeClass.nodeVersions) {
|
||||
const versions = Object.keys(nodeClass.nodeVersions);
|
||||
return Math.max(...versions.map(Number)).toString();
|
||||
}
|
||||
|
||||
// Also check class-level description for version array
|
||||
const description = this.getNodeDescription(nodeClass);
|
||||
if (description?.version) {
|
||||
if (Array.isArray(description.version)) {
|
||||
const maxVersion = Math.max(...description.version.map((v: any) => parseFloat(v.toString())));
|
||||
return maxVersion.toString();
|
||||
} else if (typeof description.version === 'number' || typeof description.version === 'string') {
|
||||
return description.version.toString();
|
||||
if (nodeClassAny.nodeVersions) {
|
||||
const versions = Object.keys(nodeClassAny.nodeVersions).map(Number);
|
||||
if (versions.length > 0) {
|
||||
const maxVersion = Math.max(...versions);
|
||||
if (!isNaN(maxVersion)) {
|
||||
return maxVersion.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Also check class-level description for version array
|
||||
const description = this.getNodeDescription(nodeClass);
|
||||
const desc = description as any; // Strategic assertion for version property
|
||||
if (desc?.version) {
|
||||
if (Array.isArray(desc.version)) {
|
||||
const numericVersions = desc.version.map((v: any) => parseFloat(v.toString()));
|
||||
if (numericVersions.length > 0) {
|
||||
const maxVersion = Math.max(...numericVersions);
|
||||
if (!isNaN(maxVersion)) {
|
||||
return maxVersion.toString();
|
||||
}
|
||||
}
|
||||
} else if (typeof desc.version === 'number' || typeof desc.version === 'string') {
|
||||
return desc.version.toString();
|
||||
}
|
||||
}
|
||||
|
||||
// Default to version 1
|
||||
return '1';
|
||||
}
|
||||
|
||||
private detectVersioned(nodeClass: any): boolean {
|
||||
private detectVersioned(nodeClass: NodeClass): boolean {
|
||||
// Check instance-level properties first
|
||||
try {
|
||||
const instance = typeof nodeClass === 'function' ? new nodeClass() : nodeClass;
|
||||
|
||||
// Strategic any assertion - instance could be INodeType or IVersionedNodeType
|
||||
const inst = instance as any;
|
||||
|
||||
// Check for instance baseDescription with defaultVersion
|
||||
if (instance?.baseDescription?.defaultVersion) {
|
||||
if (inst?.baseDescription?.defaultVersion) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Check for nodeVersions
|
||||
if (instance?.nodeVersions) {
|
||||
if (inst?.nodeVersions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Check for version array in description
|
||||
if (instance?.description?.version && Array.isArray(instance.description.version)) {
|
||||
if (inst?.description?.version && Array.isArray(inst.description.version)) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Some nodes might require parameters to instantiate
|
||||
// Try class-level checks
|
||||
}
|
||||
|
||||
|
||||
// Check class-level nodeVersions
|
||||
if (nodeClass.nodeVersions || nodeClass.baseDescription?.defaultVersion) {
|
||||
// Strategic any assertion for class-level property access
|
||||
const nodeClassAny = nodeClass as any;
|
||||
if (nodeClassAny.nodeVersions || nodeClassAny.baseDescription?.defaultVersion) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Also check class-level description for version array
|
||||
const description = this.getNodeDescription(nodeClass);
|
||||
if (description?.version && Array.isArray(description.version)) {
|
||||
const desc = description as any; // Strategic assertion for version property
|
||||
if (desc?.version && Array.isArray(desc.version)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private extractOutputs(description: any): { outputs?: any[], outputNames?: string[] } {
|
||||
private extractOutputs(description: INodeTypeBaseDescription | INodeTypeDescription): { outputs?: any[], outputNames?: string[] } {
|
||||
const result: { outputs?: any[], outputNames?: string[] } = {};
|
||||
|
||||
// Strategic any assertion for outputs/outputNames properties
|
||||
const desc = description as any;
|
||||
|
||||
// First check the base description
|
||||
if (description.outputs) {
|
||||
result.outputs = Array.isArray(description.outputs) ? description.outputs : [description.outputs];
|
||||
if (desc.outputs) {
|
||||
result.outputs = Array.isArray(desc.outputs) ? desc.outputs : [desc.outputs];
|
||||
}
|
||||
|
||||
if (description.outputNames) {
|
||||
result.outputNames = Array.isArray(description.outputNames) ? description.outputNames : [description.outputNames];
|
||||
|
||||
if (desc.outputNames) {
|
||||
result.outputNames = Array.isArray(desc.outputNames) ? desc.outputNames : [desc.outputNames];
|
||||
}
|
||||
|
||||
|
||||
// If no outputs found and this is a versioned node, check the latest version
|
||||
if (!result.outputs && !result.outputNames) {
|
||||
const nodeClass = this.currentNodeClass; // We'll need to track this
|
||||
if (nodeClass) {
|
||||
try {
|
||||
const instance = new nodeClass();
|
||||
if (instance.nodeVersions) {
|
||||
const instance = typeof nodeClass === 'function' ? new nodeClass() : nodeClass;
|
||||
// Strategic any assertion for instance properties
|
||||
const inst = instance as any;
|
||||
if (inst.nodeVersions) {
|
||||
// Get the latest version
|
||||
const versions = Object.keys(instance.nodeVersions).map(Number);
|
||||
const latestVersion = Math.max(...versions);
|
||||
const versionedDescription = instance.nodeVersions[latestVersion]?.description;
|
||||
const versions = Object.keys(inst.nodeVersions).map(Number);
|
||||
if (versions.length > 0) {
|
||||
const latestVersion = Math.max(...versions);
|
||||
if (!isNaN(latestVersion)) {
|
||||
const versionedDescription = inst.nodeVersions[latestVersion]?.description;
|
||||
|
||||
if (versionedDescription) {
|
||||
if (versionedDescription.outputs) {
|
||||
@@ -262,11 +355,13 @@ export class NodeParser {
|
||||
}
|
||||
|
||||
if (versionedDescription.outputNames) {
|
||||
result.outputNames = Array.isArray(versionedDescription.outputNames)
|
||||
? versionedDescription.outputNames
|
||||
result.outputNames = Array.isArray(versionedDescription.outputNames)
|
||||
? versionedDescription.outputNames
|
||||
: [versionedDescription.outputNames];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors from instantiating node
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { NodeClass } from '../types/node-types';
|
||||
|
||||
export class PropertyExtractor {
|
||||
/**
|
||||
* Extract properties with proper handling of n8n's complex structures
|
||||
*/
|
||||
extractProperties(nodeClass: any): any[] {
|
||||
extractProperties(nodeClass: NodeClass): any[] {
|
||||
const properties: any[] = [];
|
||||
|
||||
// First try to get instance-level properties
|
||||
@@ -15,12 +17,16 @@ export class PropertyExtractor {
|
||||
|
||||
// Handle versioned nodes - check instance for nodeVersions
|
||||
if (instance?.nodeVersions) {
|
||||
const versions = Object.keys(instance.nodeVersions);
|
||||
const latestVersion = Math.max(...versions.map(Number));
|
||||
const versionedNode = instance.nodeVersions[latestVersion];
|
||||
|
||||
if (versionedNode?.description?.properties) {
|
||||
return this.normalizeProperties(versionedNode.description.properties);
|
||||
const versions = Object.keys(instance.nodeVersions).map(Number);
|
||||
if (versions.length > 0) {
|
||||
const latestVersion = Math.max(...versions);
|
||||
if (!isNaN(latestVersion)) {
|
||||
const versionedNode = instance.nodeVersions[latestVersion];
|
||||
|
||||
if (versionedNode?.description?.properties) {
|
||||
return this.normalizeProperties(versionedNode.description.properties);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,30 +41,36 @@ export class PropertyExtractor {
|
||||
return properties;
|
||||
}
|
||||
|
||||
private getNodeDescription(nodeClass: any): any {
|
||||
private getNodeDescription(nodeClass: NodeClass): any {
|
||||
// Try to get description from the class first
|
||||
let description: any;
|
||||
|
||||
|
||||
if (typeof nodeClass === 'function') {
|
||||
// Try to instantiate to get description
|
||||
try {
|
||||
const instance = new nodeClass();
|
||||
description = instance.description || instance.baseDescription || {};
|
||||
// Strategic any assertion for instance properties
|
||||
const inst = instance as any;
|
||||
description = inst.description || inst.baseDescription || {};
|
||||
} catch (e) {
|
||||
// Some nodes might require parameters to instantiate
|
||||
description = nodeClass.description || {};
|
||||
// Strategic any assertion for class-level properties
|
||||
const nodeClassAny = nodeClass as any;
|
||||
description = nodeClassAny.description || {};
|
||||
}
|
||||
} else {
|
||||
description = nodeClass.description || {};
|
||||
// Strategic any assertion for instance properties
|
||||
const inst = nodeClass as any;
|
||||
description = inst.description || {};
|
||||
}
|
||||
|
||||
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract operations from both declarative and programmatic nodes
|
||||
*/
|
||||
extractOperations(nodeClass: any): any[] {
|
||||
extractOperations(nodeClass: NodeClass): any[] {
|
||||
const operations: any[] = [];
|
||||
|
||||
// First try to get instance-level data
|
||||
@@ -71,12 +83,16 @@ export class PropertyExtractor {
|
||||
|
||||
// Handle versioned nodes
|
||||
if (instance?.nodeVersions) {
|
||||
const versions = Object.keys(instance.nodeVersions);
|
||||
const latestVersion = Math.max(...versions.map(Number));
|
||||
const versionedNode = instance.nodeVersions[latestVersion];
|
||||
|
||||
if (versionedNode?.description) {
|
||||
return this.extractOperationsFromDescription(versionedNode.description);
|
||||
const versions = Object.keys(instance.nodeVersions).map(Number);
|
||||
if (versions.length > 0) {
|
||||
const latestVersion = Math.max(...versions);
|
||||
if (!isNaN(latestVersion)) {
|
||||
const versionedNode = instance.nodeVersions[latestVersion];
|
||||
|
||||
if (versionedNode?.description) {
|
||||
return this.extractOperationsFromDescription(versionedNode.description);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,33 +154,35 @@ export class PropertyExtractor {
|
||||
/**
|
||||
* Deep search for AI tool capability
|
||||
*/
|
||||
detectAIToolCapability(nodeClass: any): boolean {
|
||||
detectAIToolCapability(nodeClass: NodeClass): boolean {
|
||||
const description = this.getNodeDescription(nodeClass);
|
||||
|
||||
|
||||
// Direct property check
|
||||
if (description?.usableAsTool === true) return true;
|
||||
|
||||
|
||||
// Check in actions for declarative nodes
|
||||
if (description?.actions?.some((a: any) => a.usableAsTool === true)) return true;
|
||||
|
||||
|
||||
// Check versioned nodes
|
||||
if (nodeClass.nodeVersions) {
|
||||
for (const version of Object.values(nodeClass.nodeVersions)) {
|
||||
// Strategic any assertion for nodeVersions property
|
||||
const nodeClassAny = nodeClass as any;
|
||||
if (nodeClassAny.nodeVersions) {
|
||||
for (const version of Object.values(nodeClassAny.nodeVersions)) {
|
||||
if ((version as any).description?.usableAsTool === true) return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check for specific AI-related properties
|
||||
const aiIndicators = ['openai', 'anthropic', 'huggingface', 'cohere', 'ai'];
|
||||
const nodeName = description?.name?.toLowerCase() || '';
|
||||
|
||||
|
||||
return aiIndicators.some(indicator => nodeName.includes(indicator));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract credential requirements with proper structure
|
||||
*/
|
||||
extractCredentials(nodeClass: any): any[] {
|
||||
extractCredentials(nodeClass: NodeClass): any[] {
|
||||
const credentials: any[] = [];
|
||||
|
||||
// First try to get instance-level data
|
||||
@@ -177,12 +195,16 @@ export class PropertyExtractor {
|
||||
|
||||
// Handle versioned nodes
|
||||
if (instance?.nodeVersions) {
|
||||
const versions = Object.keys(instance.nodeVersions);
|
||||
const latestVersion = Math.max(...versions.map(Number));
|
||||
const versionedNode = instance.nodeVersions[latestVersion];
|
||||
|
||||
if (versionedNode?.description?.credentials) {
|
||||
return versionedNode.description.credentials;
|
||||
const versions = Object.keys(instance.nodeVersions).map(Number);
|
||||
if (versions.length > 0) {
|
||||
const latestVersion = Math.max(...versions);
|
||||
if (!isNaN(latestVersion)) {
|
||||
const versionedNode = instance.nodeVersions[latestVersion];
|
||||
|
||||
if (versionedNode?.description?.credentials) {
|
||||
return versionedNode.description.credentials;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
import type {
|
||||
NodeClass,
|
||||
VersionedNodeInstance
|
||||
} from '../types/node-types';
|
||||
import {
|
||||
isVersionedNodeInstance,
|
||||
isVersionedNodeClass
|
||||
} from '../types/node-types';
|
||||
import type { INodeTypeBaseDescription, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export interface ParsedNode {
|
||||
style: 'declarative' | 'programmatic';
|
||||
nodeType: string;
|
||||
@@ -15,24 +25,32 @@ export interface ParsedNode {
|
||||
}
|
||||
|
||||
export class SimpleParser {
|
||||
parse(nodeClass: any): ParsedNode {
|
||||
let description: any;
|
||||
parse(nodeClass: NodeClass): ParsedNode {
|
||||
let description: INodeTypeBaseDescription | INodeTypeDescription;
|
||||
let isVersioned = false;
|
||||
|
||||
|
||||
// Try to get description from the class
|
||||
try {
|
||||
// Check if it's a versioned node (has baseDescription and nodeVersions)
|
||||
if (typeof nodeClass === 'function' && nodeClass.prototype &&
|
||||
nodeClass.prototype.constructor &&
|
||||
nodeClass.prototype.constructor.name === 'VersionedNodeType') {
|
||||
// Check if it's a versioned node using type guard
|
||||
if (isVersionedNodeClass(nodeClass)) {
|
||||
// This is a VersionedNodeType class - instantiate it
|
||||
const instance = new nodeClass();
|
||||
description = instance.baseDescription || {};
|
||||
const instance = new (nodeClass as new () => VersionedNodeInstance)();
|
||||
// Strategic any assertion for accessing both description and baseDescription
|
||||
const inst = instance as any;
|
||||
// Try description first (real VersionedNodeType with getter)
|
||||
// Only fallback to baseDescription if nodeVersions exists (complete VersionedNodeType mock)
|
||||
// This prevents using baseDescription for incomplete mocks that test edge cases
|
||||
description = inst.description || (inst.nodeVersions ? inst.baseDescription : undefined);
|
||||
|
||||
// If still undefined (incomplete mock), use empty object to allow graceful failure later
|
||||
if (!description) {
|
||||
description = {} as any;
|
||||
}
|
||||
isVersioned = true;
|
||||
|
||||
|
||||
// For versioned nodes, try to get properties from the current version
|
||||
if (instance.nodeVersions && instance.currentVersion) {
|
||||
const currentVersionNode = instance.nodeVersions[instance.currentVersion];
|
||||
if (inst.nodeVersions && inst.currentVersion) {
|
||||
const currentVersionNode = inst.nodeVersions[inst.currentVersion];
|
||||
if (currentVersionNode && currentVersionNode.description) {
|
||||
// Merge baseDescription with version-specific description
|
||||
description = { ...description, ...currentVersionNode.description };
|
||||
@@ -42,63 +60,76 @@ export class SimpleParser {
|
||||
// Try to instantiate to get description
|
||||
try {
|
||||
const instance = new nodeClass();
|
||||
description = instance.description || {};
|
||||
|
||||
// For versioned nodes, we might need to look deeper
|
||||
if (!description.name && instance.baseDescription) {
|
||||
description = instance.baseDescription;
|
||||
isVersioned = true;
|
||||
description = instance.description;
|
||||
// If description is empty or missing name, check for baseDescription fallback
|
||||
if (!description || !description.name) {
|
||||
const inst = instance as any;
|
||||
if (inst.baseDescription?.name) {
|
||||
description = inst.baseDescription;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Some nodes might require parameters to instantiate
|
||||
// Try to access static properties or look for common patterns
|
||||
description = {};
|
||||
description = {} as any;
|
||||
}
|
||||
} else {
|
||||
// Maybe it's already an instance
|
||||
description = nodeClass.description || {};
|
||||
description = nodeClass.description;
|
||||
// If description is empty or missing name, check for baseDescription fallback
|
||||
if (!description || !description.name) {
|
||||
const inst = nodeClass as any;
|
||||
if (inst.baseDescription?.name) {
|
||||
description = inst.baseDescription;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If instantiation fails, try to get static description
|
||||
description = nodeClass.description || {};
|
||||
description = (nodeClass as any).description || ({} as any);
|
||||
}
|
||||
|
||||
const isDeclarative = !!description.routing;
|
||||
|
||||
|
||||
// Strategic any assertion for properties that don't exist on both union sides
|
||||
const desc = description as any;
|
||||
const isDeclarative = !!desc.routing;
|
||||
|
||||
// Ensure we have a valid nodeType
|
||||
if (!description.name) {
|
||||
throw new Error('Node is missing name property');
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
style: isDeclarative ? 'declarative' : 'programmatic',
|
||||
nodeType: description.name,
|
||||
displayName: description.displayName || description.name,
|
||||
description: description.description,
|
||||
category: description.group?.[0] || description.categories?.[0],
|
||||
properties: description.properties || [],
|
||||
credentials: description.credentials || [],
|
||||
isAITool: description.usableAsTool === true,
|
||||
category: description.group?.[0] || desc.categories?.[0],
|
||||
properties: desc.properties || [],
|
||||
credentials: desc.credentials || [],
|
||||
isAITool: desc.usableAsTool === true,
|
||||
isTrigger: this.detectTrigger(description),
|
||||
isWebhook: description.webhooks?.length > 0,
|
||||
operations: isDeclarative ? this.extractOperations(description.routing) : this.extractProgrammaticOperations(description),
|
||||
isWebhook: desc.webhooks?.length > 0,
|
||||
operations: isDeclarative ? this.extractOperations(desc.routing) : this.extractProgrammaticOperations(desc),
|
||||
version: this.extractVersion(nodeClass),
|
||||
isVersioned: isVersioned || this.isVersionedNode(nodeClass) || Array.isArray(description.version) || description.defaultVersion !== undefined
|
||||
isVersioned: isVersioned || this.isVersionedNode(nodeClass) || Array.isArray(desc.version) || desc.defaultVersion !== undefined
|
||||
};
|
||||
}
|
||||
|
||||
private detectTrigger(description: any): boolean {
|
||||
private detectTrigger(description: INodeTypeBaseDescription | INodeTypeDescription): boolean {
|
||||
// Primary check: group includes 'trigger'
|
||||
if (description.group && Array.isArray(description.group)) {
|
||||
if (description.group.includes('trigger')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Strategic any assertion for properties that only exist on INodeTypeDescription
|
||||
const desc = description as any;
|
||||
|
||||
// Fallback checks for edge cases
|
||||
return description.polling === true ||
|
||||
description.trigger === true ||
|
||||
description.eventTrigger === true ||
|
||||
return desc.polling === true ||
|
||||
desc.trigger === true ||
|
||||
desc.eventTrigger === true ||
|
||||
description.name?.toLowerCase().includes('trigger');
|
||||
}
|
||||
|
||||
@@ -186,48 +217,109 @@ export class SimpleParser {
|
||||
return operations;
|
||||
}
|
||||
|
||||
private extractVersion(nodeClass: any): string {
|
||||
/**
|
||||
* Extracts the version from a node class.
|
||||
*
|
||||
* Priority Chain (same as node-parser.ts):
|
||||
* 1. Instance currentVersion (VersionedNodeType's computed property)
|
||||
* 2. Instance description.defaultVersion (explicit default)
|
||||
* 3. Instance nodeVersions (fallback to max available version)
|
||||
* 4. Instance description.version (simple versioning)
|
||||
* 5. Class-level properties (if instantiation fails)
|
||||
* 6. Default to "1"
|
||||
*
|
||||
* Critical Fix (v2.17.4): Removed check for non-existent instance.baseDescription.defaultVersion
|
||||
* which caused AI Agent and other VersionedNodeType nodes to return wrong versions.
|
||||
*
|
||||
* @param nodeClass - The node class or instance to extract version from
|
||||
* @returns The version as a string
|
||||
*/
|
||||
private extractVersion(nodeClass: NodeClass): string {
|
||||
// Try to get version from instance first
|
||||
try {
|
||||
const instance = typeof nodeClass === 'function' ? new nodeClass() : nodeClass;
|
||||
|
||||
// Check instance baseDescription
|
||||
if (instance?.baseDescription?.defaultVersion) {
|
||||
return instance.baseDescription.defaultVersion.toString();
|
||||
// Strategic any assertion for instance properties
|
||||
const inst = instance as any;
|
||||
|
||||
// PRIORITY 1: Check currentVersion (what VersionedNodeType actually uses)
|
||||
// For VersionedNodeType, currentVersion = defaultVersion ?? max(nodeVersions)
|
||||
if (inst?.currentVersion !== undefined) {
|
||||
return inst.currentVersion.toString();
|
||||
}
|
||||
|
||||
// Check instance description version
|
||||
if (instance?.description?.version) {
|
||||
return instance.description.version.toString();
|
||||
|
||||
// PRIORITY 2: Handle instance-level description.defaultVersion
|
||||
// VersionedNodeType stores baseDescription as 'description', not 'baseDescription'
|
||||
if (inst?.description?.defaultVersion) {
|
||||
return inst.description.defaultVersion.toString();
|
||||
}
|
||||
|
||||
// PRIORITY 3: Handle instance-level nodeVersions (fallback to max)
|
||||
if (inst?.nodeVersions) {
|
||||
const versions = Object.keys(inst.nodeVersions).map(Number);
|
||||
if (versions.length > 0) {
|
||||
const maxVersion = Math.max(...versions);
|
||||
if (!isNaN(maxVersion)) {
|
||||
return maxVersion.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PRIORITY 4: Check instance description version
|
||||
if (inst?.description?.version) {
|
||||
return inst.description.version.toString();
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore instantiation errors
|
||||
}
|
||||
|
||||
// Check class-level properties
|
||||
if (nodeClass.baseDescription?.defaultVersion) {
|
||||
return nodeClass.baseDescription.defaultVersion.toString();
|
||||
|
||||
// PRIORITY 5: Check class-level properties (if instantiation failed)
|
||||
// Strategic any assertion for class-level properties
|
||||
const nodeClassAny = nodeClass as any;
|
||||
if (nodeClassAny.description?.defaultVersion) {
|
||||
return nodeClassAny.description.defaultVersion.toString();
|
||||
}
|
||||
|
||||
return nodeClass.description?.version || '1';
|
||||
|
||||
if (nodeClassAny.nodeVersions) {
|
||||
const versions = Object.keys(nodeClassAny.nodeVersions).map(Number);
|
||||
if (versions.length > 0) {
|
||||
const maxVersion = Math.max(...versions);
|
||||
if (!isNaN(maxVersion)) {
|
||||
return maxVersion.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PRIORITY 6: Default to version 1
|
||||
return nodeClassAny.description?.version || '1';
|
||||
}
|
||||
|
||||
private isVersionedNode(nodeClass: any): boolean {
|
||||
// Check for VersionedNodeType pattern
|
||||
if (nodeClass.baseDescription && nodeClass.nodeVersions) {
|
||||
private isVersionedNode(nodeClass: NodeClass): boolean {
|
||||
// Strategic any assertion for class-level properties
|
||||
const nodeClassAny = nodeClass as any;
|
||||
|
||||
// Check for VersionedNodeType pattern at class level
|
||||
if (nodeClassAny.baseDescription && nodeClassAny.nodeVersions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Check for inline versioning pattern (like Code node)
|
||||
try {
|
||||
const instance = typeof nodeClass === 'function' ? new nodeClass() : nodeClass;
|
||||
const description = instance.description || {};
|
||||
|
||||
// Strategic any assertion for instance properties
|
||||
const inst = instance as any;
|
||||
|
||||
// Check for VersionedNodeType pattern at instance level
|
||||
if (inst.baseDescription && inst.nodeVersions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const description = inst.description || {};
|
||||
|
||||
// If version is an array, it's versioned
|
||||
if (Array.isArray(description.version)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// If it has defaultVersion, it's likely versioned
|
||||
if (description.defaultVersion !== undefined) {
|
||||
return true;
|
||||
@@ -235,7 +327,7 @@ export class SimpleParser {
|
||||
} catch (e) {
|
||||
// Ignore instantiation errors
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -417,12 +417,28 @@ async function generateTemplateMetadata(db: any, service: TemplateService) {
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse workflow for template ${t.id}:`, error);
|
||||
}
|
||||
|
||||
|
||||
// Parse nodes_used safely
|
||||
let nodes: string[] = [];
|
||||
try {
|
||||
if (t.nodes_used) {
|
||||
nodes = JSON.parse(t.nodes_used);
|
||||
// Ensure it's an array
|
||||
if (!Array.isArray(nodes)) {
|
||||
console.warn(`Template ${t.id} has invalid nodes_used (not an array), using empty array`);
|
||||
nodes = [];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse nodes_used for template ${t.id}:`, error);
|
||||
nodes = [];
|
||||
}
|
||||
|
||||
return {
|
||||
templateId: t.id,
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
nodes: JSON.parse(t.nodes_used),
|
||||
nodes: nodes,
|
||||
workflow
|
||||
};
|
||||
});
|
||||
|
||||
@@ -31,13 +31,19 @@ export interface ValidationWarning {
|
||||
}
|
||||
|
||||
export class ConfigValidator {
|
||||
/**
|
||||
* UI-only property types that should not be validated as configuration
|
||||
*/
|
||||
private static readonly UI_ONLY_TYPES = ['notice', 'callout', 'infoBox', 'info'];
|
||||
|
||||
/**
|
||||
* Validate a node configuration
|
||||
*/
|
||||
static validate(
|
||||
nodeType: string,
|
||||
config: Record<string, any>,
|
||||
properties: any[]
|
||||
nodeType: string,
|
||||
config: Record<string, any>,
|
||||
properties: any[],
|
||||
userProvidedKeys?: Set<string> // NEW: Track user-provided properties to avoid warning about defaults
|
||||
): ValidationResult {
|
||||
// Input validation
|
||||
if (!config || typeof config !== 'object') {
|
||||
@@ -46,7 +52,7 @@ export class ConfigValidator {
|
||||
if (!properties || !Array.isArray(properties)) {
|
||||
throw new TypeError('Properties must be a non-null array');
|
||||
}
|
||||
|
||||
|
||||
const errors: ValidationError[] = [];
|
||||
const warnings: ValidationWarning[] = [];
|
||||
const suggestions: string[] = [];
|
||||
@@ -69,8 +75,8 @@ export class ConfigValidator {
|
||||
this.performNodeSpecificValidation(nodeType, config, errors, warnings, suggestions, autofix);
|
||||
|
||||
// Check for common issues
|
||||
this.checkCommonIssues(nodeType, config, properties, warnings, suggestions);
|
||||
|
||||
this.checkCommonIssues(nodeType, config, properties, warnings, suggestions, userProvidedKeys);
|
||||
|
||||
// Security checks
|
||||
this.performSecurityChecks(nodeType, config, warnings);
|
||||
|
||||
@@ -234,8 +240,56 @@ export class ConfigValidator {
|
||||
message: `Property '${key}' must be a boolean, got ${typeof value}`,
|
||||
fix: `Change ${key} to true or false`
|
||||
});
|
||||
} else if (prop.type === 'resourceLocator') {
|
||||
// resourceLocator validation: Used by AI model nodes (OpenAI, Anthropic, etc.)
|
||||
// Must be an object with required properties:
|
||||
// - mode: string ('list' | 'id' | 'url')
|
||||
// - value: any (the actual model/resource identifier)
|
||||
// Common mistake: passing string directly instead of object structure
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
const fixValue = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
errors.push({
|
||||
type: 'invalid_type',
|
||||
property: key,
|
||||
message: `Property '${key}' is a resourceLocator and must be an object with 'mode' and 'value' properties, got ${typeof value}`,
|
||||
fix: `Change ${key} to { mode: "list", value: ${JSON.stringify(fixValue)} } or { mode: "id", value: ${JSON.stringify(fixValue)} }`
|
||||
});
|
||||
} else {
|
||||
// Check required properties
|
||||
if (!value.mode) {
|
||||
errors.push({
|
||||
type: 'missing_required',
|
||||
property: `${key}.mode`,
|
||||
message: `resourceLocator '${key}' is missing required property 'mode'`,
|
||||
fix: `Add mode property: { mode: "list", value: ${JSON.stringify(value.value || '')} }`
|
||||
});
|
||||
} else if (typeof value.mode !== 'string') {
|
||||
errors.push({
|
||||
type: 'invalid_type',
|
||||
property: `${key}.mode`,
|
||||
message: `resourceLocator '${key}.mode' must be a string, got ${typeof value.mode}`,
|
||||
fix: `Set mode to "list" or "id"`
|
||||
});
|
||||
} else if (!['list', 'id', 'url'].includes(value.mode)) {
|
||||
errors.push({
|
||||
type: 'invalid_value',
|
||||
property: `${key}.mode`,
|
||||
message: `resourceLocator '${key}.mode' must be 'list', 'id', or 'url', got '${value.mode}'`,
|
||||
fix: `Change mode to "list", "id", or "url"`
|
||||
});
|
||||
}
|
||||
|
||||
if (value.value === undefined) {
|
||||
errors.push({
|
||||
type: 'missing_required',
|
||||
property: `${key}.value`,
|
||||
message: `resourceLocator '${key}' is missing required property 'value'`,
|
||||
fix: `Add value property to specify the ${prop.displayName || key}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Options validation
|
||||
if (prop.type === 'options' && prop.options) {
|
||||
const validValues = prop.options.map((opt: any) =>
|
||||
@@ -445,30 +499,48 @@ export class ConfigValidator {
|
||||
config: Record<string, any>,
|
||||
properties: any[],
|
||||
warnings: ValidationWarning[],
|
||||
suggestions: string[]
|
||||
suggestions: string[],
|
||||
userProvidedKeys?: Set<string> // NEW: Only warn about user-provided properties
|
||||
): void {
|
||||
// Skip visibility checks for Code nodes as they have simple property structure
|
||||
if (nodeType === 'nodes-base.code') {
|
||||
// Code nodes don't have complex displayOptions, so skip visibility warnings
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check for properties that won't be used
|
||||
const visibleProps = properties.filter(p => this.isPropertyVisible(p, config));
|
||||
const configuredKeys = Object.keys(config);
|
||||
|
||||
|
||||
for (const key of configuredKeys) {
|
||||
// Skip internal properties that are always present
|
||||
if (key === '@version' || key.startsWith('_')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// CRITICAL FIX: Only warn about properties the user actually provided, not defaults
|
||||
if (userProvidedKeys && !userProvidedKeys.has(key)) {
|
||||
continue; // Skip properties that were added as defaults
|
||||
}
|
||||
|
||||
// Find the property definition
|
||||
const prop = properties.find(p => p.name === key);
|
||||
|
||||
// Skip UI-only properties (notice, callout, etc.) - they're not configuration
|
||||
if (prop && this.UI_ONLY_TYPES.includes(prop.type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if property is visible with current settings
|
||||
if (!visibleProps.find(p => p.name === key)) {
|
||||
// Get visibility requirements for better error message
|
||||
const visibilityReq = this.getVisibilityRequirement(prop, config);
|
||||
|
||||
warnings.push({
|
||||
type: 'inefficient',
|
||||
property: key,
|
||||
message: `Property '${key}' is configured but won't be used due to current settings`,
|
||||
suggestion: 'Remove this property or adjust other settings to make it visible'
|
||||
message: `Property '${prop?.displayName || key}' won't be used - not visible with current settings`,
|
||||
suggestion: visibilityReq || 'Remove this property or adjust other settings to make it visible'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -517,6 +589,36 @@ export class ConfigValidator {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get visibility requirement for a property
|
||||
* Explains what needs to be set for the property to be visible
|
||||
*/
|
||||
private static getVisibilityRequirement(prop: any, config: Record<string, any>): string | undefined {
|
||||
if (!prop || !prop.displayOptions?.show) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const requirements: string[] = [];
|
||||
for (const [field, values] of Object.entries(prop.displayOptions.show)) {
|
||||
const expectedValues = Array.isArray(values) ? values : [values];
|
||||
const currentValue = config[field];
|
||||
|
||||
// Only include if the current value doesn't match
|
||||
if (!expectedValues.includes(currentValue)) {
|
||||
const valueStr = expectedValues.length === 1
|
||||
? `"${expectedValues[0]}"`
|
||||
: expectedValues.map(v => `"${v}"`).join(' or ');
|
||||
requirements.push(`${field}=${valueStr}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (requirements.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `Requires: ${requirements.join(', ')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic JavaScript syntax validation
|
||||
*/
|
||||
|
||||
@@ -78,6 +78,9 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
||||
// Extract operation context from config
|
||||
const operationContext = this.extractOperationContext(config);
|
||||
|
||||
// Extract user-provided keys before applying defaults (CRITICAL FIX for warning system)
|
||||
const userProvidedKeys = new Set(Object.keys(config));
|
||||
|
||||
// Filter properties based on mode and operation, and get config with defaults
|
||||
const { properties: filteredProperties, configWithDefaults } = this.filterPropertiesByMode(
|
||||
properties,
|
||||
@@ -87,7 +90,8 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
||||
);
|
||||
|
||||
// Perform base validation on filtered properties with defaults applied
|
||||
const baseResult = super.validate(nodeType, configWithDefaults, filteredProperties);
|
||||
// Pass userProvidedKeys to prevent warnings about default values
|
||||
const baseResult = super.validate(nodeType, configWithDefaults, filteredProperties, userProvidedKeys);
|
||||
|
||||
// Enhance the result
|
||||
const enhancedResult: EnhancedValidationResult = {
|
||||
@@ -469,22 +473,32 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
||||
case 'minimal':
|
||||
// Only keep missing required errors
|
||||
result.errors = result.errors.filter(e => e.type === 'missing_required');
|
||||
result.warnings = [];
|
||||
// Keep ONLY critical warnings (security and deprecated)
|
||||
result.warnings = result.warnings.filter(w =>
|
||||
w.type === 'security' || w.type === 'deprecated'
|
||||
);
|
||||
result.suggestions = [];
|
||||
break;
|
||||
|
||||
|
||||
case 'runtime':
|
||||
// Keep critical runtime errors only
|
||||
result.errors = result.errors.filter(e =>
|
||||
e.type === 'missing_required' ||
|
||||
result.errors = result.errors.filter(e =>
|
||||
e.type === 'missing_required' ||
|
||||
e.type === 'invalid_value' ||
|
||||
(e.type === 'invalid_type' && e.message.includes('undefined'))
|
||||
);
|
||||
// Keep only security warnings
|
||||
result.warnings = result.warnings.filter(w => w.type === 'security');
|
||||
// Keep security and deprecated warnings, REMOVE property visibility warnings
|
||||
result.warnings = result.warnings.filter(w => {
|
||||
if (w.type === 'security' || w.type === 'deprecated') return true;
|
||||
// FILTER OUT property visibility warnings (too noisy)
|
||||
if (w.type === 'inefficient' && w.message && w.message.includes('not visible')) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
result.suggestions = [];
|
||||
break;
|
||||
|
||||
|
||||
case 'strict':
|
||||
// Keep everything, add more suggestions
|
||||
if (result.warnings.length === 0 && result.errors.length === 0) {
|
||||
@@ -494,14 +508,28 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
||||
// Require error handling for external service nodes
|
||||
this.enforceErrorHandlingForProfile(result, profile);
|
||||
break;
|
||||
|
||||
|
||||
case 'ai-friendly':
|
||||
default:
|
||||
// Current behavior - balanced for AI agents
|
||||
// Filter out noise but keep helpful warnings
|
||||
result.warnings = result.warnings.filter(w =>
|
||||
w.type !== 'inefficient' || !w.property?.startsWith('_')
|
||||
);
|
||||
result.warnings = result.warnings.filter(w => {
|
||||
// Keep security and deprecated warnings
|
||||
if (w.type === 'security' || w.type === 'deprecated') return true;
|
||||
// Keep missing common properties
|
||||
if (w.type === 'missing_common') return true;
|
||||
// Keep best practice warnings
|
||||
if (w.type === 'best_practice') return true;
|
||||
// FILTER OUT inefficient warnings about property visibility (now fixed at source)
|
||||
if (w.type === 'inefficient' && w.message && w.message.includes('not visible')) {
|
||||
return false; // These are now rare due to userProvidedKeys fix
|
||||
}
|
||||
// Filter out internal property warnings
|
||||
if (w.type === 'inefficient' && w.property?.startsWith('_')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Add error handling suggestions for AI-friendly profile
|
||||
this.addErrorHandlingSuggestions(result);
|
||||
break;
|
||||
|
||||
@@ -397,14 +397,7 @@ export class WorkflowValidator {
|
||||
node.type = normalizedType;
|
||||
}
|
||||
|
||||
// Skip ALL node repository validation for langchain nodes
|
||||
// They have dedicated AI-specific validators in validateAISpecificNodes()
|
||||
// This prevents parameter validation conflicts and ensures proper AI validation
|
||||
if (normalizedType.startsWith('nodes-langchain.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get node definition using normalized type
|
||||
// Get node definition using normalized type (needed for typeVersion validation)
|
||||
const nodeInfo = this.nodeRepository.getNode(normalizedType);
|
||||
|
||||
if (!nodeInfo) {
|
||||
@@ -451,7 +444,10 @@ export class WorkflowValidator {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate typeVersion for versioned nodes
|
||||
// Validate typeVersion for ALL versioned nodes (including langchain nodes)
|
||||
// CRITICAL: This MUST run BEFORE the langchain skip below!
|
||||
// Otherwise, langchain nodes with invalid typeVersion (e.g., 99999) would pass validation
|
||||
// but fail at runtime in n8n. This was the bug fixed in v2.17.4.
|
||||
if (nodeInfo.isVersioned) {
|
||||
// Check if typeVersion is missing
|
||||
if (!node.typeVersion) {
|
||||
@@ -461,14 +457,14 @@ export class WorkflowValidator {
|
||||
nodeName: node.name,
|
||||
message: `Missing required property 'typeVersion'. Add typeVersion: ${nodeInfo.version || 1}`
|
||||
});
|
||||
}
|
||||
// Check if typeVersion is invalid
|
||||
else if (typeof node.typeVersion !== 'number' || node.typeVersion < 1) {
|
||||
}
|
||||
// Check if typeVersion is invalid (must be non-negative number, version 0 is valid)
|
||||
else if (typeof node.typeVersion !== 'number' || node.typeVersion < 0) {
|
||||
result.errors.push({
|
||||
type: 'error',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
message: `Invalid typeVersion: ${node.typeVersion}. Must be a positive number`
|
||||
message: `Invalid typeVersion: ${node.typeVersion}. Must be a non-negative number`
|
||||
});
|
||||
}
|
||||
// Check if typeVersion is outdated (less than latest)
|
||||
@@ -491,6 +487,13 @@ export class WorkflowValidator {
|
||||
}
|
||||
}
|
||||
|
||||
// Skip PARAMETER validation for langchain nodes (but NOT typeVersion validation above!)
|
||||
// Langchain nodes have dedicated AI-specific validators in validateAISpecificNodes()
|
||||
// which handle their unique parameter structures (AI connections, tool ports, etc.)
|
||||
if (normalizedType.startsWith('nodes-langchain.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate node configuration
|
||||
const nodeValidation = this.nodeValidator.validateWithMode(
|
||||
node.type,
|
||||
|
||||
@@ -37,12 +37,135 @@ export class TelemetryConfigManager {
|
||||
|
||||
/**
|
||||
* Generate a deterministic anonymous user ID based on machine characteristics
|
||||
* Uses Docker/cloud-specific method for containerized environments
|
||||
*/
|
||||
private generateUserId(): string {
|
||||
// Use boot_id for all Docker/cloud environments (stable across container updates)
|
||||
if (process.env.IS_DOCKER === 'true' || this.isCloudEnvironment()) {
|
||||
return this.generateDockerStableId();
|
||||
}
|
||||
|
||||
// Local installations use file-based method with hostname
|
||||
const machineId = `${hostname()}-${platform()}-${arch()}-${homedir()}`;
|
||||
return createHash('sha256').update(machineId).digest('hex').substring(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate stable user ID for Docker/cloud environments
|
||||
* Priority: boot_id → combined signals → generic fallback
|
||||
*/
|
||||
private generateDockerStableId(): string {
|
||||
// Priority 1: Try boot_id (stable across container recreations)
|
||||
const bootId = this.readBootId();
|
||||
if (bootId) {
|
||||
const fingerprint = `${bootId}-${platform()}-${arch()}`;
|
||||
return createHash('sha256').update(fingerprint).digest('hex').substring(0, 16);
|
||||
}
|
||||
|
||||
// Priority 2: Try combined host signals
|
||||
const combinedFingerprint = this.generateCombinedFingerprint();
|
||||
if (combinedFingerprint) {
|
||||
return combinedFingerprint;
|
||||
}
|
||||
|
||||
// Priority 3: Generic Docker ID (allows aggregate statistics)
|
||||
const genericId = `docker-${platform()}-${arch()}`;
|
||||
return createHash('sha256').update(genericId).digest('hex').substring(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read host boot_id from /proc (available in Linux containers)
|
||||
* Returns null if not available or invalid format
|
||||
*/
|
||||
private readBootId(): string | null {
|
||||
try {
|
||||
const bootIdPath = '/proc/sys/kernel/random/boot_id';
|
||||
|
||||
if (!existsSync(bootIdPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bootId = readFileSync(bootIdPath, 'utf-8').trim();
|
||||
|
||||
// Validate UUID format (8-4-4-4-12 hex digits)
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!uuidRegex.test(bootId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return bootId;
|
||||
} catch (error) {
|
||||
// File not readable or other error
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate fingerprint from combined host signals
|
||||
* Fallback for environments where boot_id is not available
|
||||
*/
|
||||
private generateCombinedFingerprint(): string | null {
|
||||
try {
|
||||
const signals: string[] = [];
|
||||
|
||||
// CPU cores (stable)
|
||||
if (existsSync('/proc/cpuinfo')) {
|
||||
const cpuinfo = readFileSync('/proc/cpuinfo', 'utf-8');
|
||||
const cores = (cpuinfo.match(/processor\s*:/g) || []).length;
|
||||
if (cores > 0) {
|
||||
signals.push(`cores:${cores}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Memory (stable)
|
||||
if (existsSync('/proc/meminfo')) {
|
||||
const meminfo = readFileSync('/proc/meminfo', 'utf-8');
|
||||
const totalMatch = meminfo.match(/MemTotal:\s+(\d+)/);
|
||||
if (totalMatch) {
|
||||
signals.push(`mem:${totalMatch[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Kernel version (stable)
|
||||
if (existsSync('/proc/version')) {
|
||||
const version = readFileSync('/proc/version', 'utf-8');
|
||||
const kernelMatch = version.match(/Linux version ([\d.]+)/);
|
||||
if (kernelMatch) {
|
||||
signals.push(`kernel:${kernelMatch[1]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Platform and arch
|
||||
signals.push(platform(), arch());
|
||||
|
||||
// Need at least 3 signals for reasonable uniqueness
|
||||
if (signals.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fingerprint = signals.join('-');
|
||||
return createHash('sha256').update(fingerprint).digest('hex').substring(0, 16);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in a cloud environment
|
||||
*/
|
||||
private isCloudEnvironment(): boolean {
|
||||
return !!(
|
||||
process.env.RAILWAY_ENVIRONMENT ||
|
||||
process.env.RENDER ||
|
||||
process.env.FLY_APP_NAME ||
|
||||
process.env.HEROKU_APP_NAME ||
|
||||
process.env.AWS_EXECUTION_ENV ||
|
||||
process.env.KUBERNETES_SERVICE_HOST ||
|
||||
process.env.GOOGLE_CLOUD_PROJECT ||
|
||||
process.env.AZURE_FUNCTIONS_ENVIRONMENT
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from disk or create default
|
||||
*/
|
||||
|
||||
@@ -45,19 +45,22 @@ export class TemplateFetcher {
|
||||
* Fetch all templates and filter to last 12 months
|
||||
* This fetches ALL pages first, then applies date filter locally
|
||||
*/
|
||||
async fetchTemplates(progressCallback?: (current: number, total: number) => void): Promise<TemplateWorkflow[]> {
|
||||
async fetchTemplates(progressCallback?: (current: number, total: number) => void, sinceDate?: Date): Promise<TemplateWorkflow[]> {
|
||||
const allTemplates = await this.fetchAllTemplates(progressCallback);
|
||||
|
||||
// Apply date filter locally after fetching all
|
||||
const oneYearAgo = new Date();
|
||||
oneYearAgo.setMonth(oneYearAgo.getMonth() - 12);
|
||||
|
||||
|
||||
// Use provided date or default to 12 months ago
|
||||
const cutoffDate = sinceDate || (() => {
|
||||
const oneYearAgo = new Date();
|
||||
oneYearAgo.setMonth(oneYearAgo.getMonth() - 12);
|
||||
return oneYearAgo;
|
||||
})();
|
||||
|
||||
const recentTemplates = allTemplates.filter((w: TemplateWorkflow) => {
|
||||
const createdDate = new Date(w.createdAt);
|
||||
return createdDate >= oneYearAgo;
|
||||
return createdDate >= cutoffDate;
|
||||
});
|
||||
|
||||
logger.info(`Filtered to ${recentTemplates.length} templates from last 12 months (out of ${allTemplates.length} total)`);
|
||||
|
||||
logger.info(`Filtered to ${recentTemplates.length} templates since ${cutoffDate.toISOString().split('T')[0]} (out of ${allTemplates.length} total)`);
|
||||
return recentTemplates;
|
||||
}
|
||||
|
||||
|
||||
@@ -442,7 +442,19 @@ export class TemplateRepository {
|
||||
const rows = this.db.prepare('SELECT id FROM templates').all() as { id: number }[];
|
||||
return new Set(rows.map(r => r.id));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the most recent template creation date
|
||||
* Used in update mode to fetch only newer templates
|
||||
*/
|
||||
getMostRecentTemplateDate(): Date | null {
|
||||
const result = this.db.prepare('SELECT MAX(created_at) as max_date FROM templates').get() as { max_date: string | null } | undefined;
|
||||
if (!result || !result.max_date) {
|
||||
return null;
|
||||
}
|
||||
return new Date(result.max_date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a template exists in the database
|
||||
*/
|
||||
|
||||
@@ -319,22 +319,38 @@ export class TemplateService {
|
||||
|
||||
// Get existing template IDs if in update mode
|
||||
let existingIds: Set<number> = new Set();
|
||||
let sinceDate: Date | undefined;
|
||||
|
||||
if (mode === 'update') {
|
||||
existingIds = this.repository.getExistingTemplateIds();
|
||||
logger.info(`Update mode: Found ${existingIds.size} existing templates in database`);
|
||||
|
||||
// Get most recent template date and fetch only templates from last 2 weeks
|
||||
const mostRecentDate = this.repository.getMostRecentTemplateDate();
|
||||
if (mostRecentDate) {
|
||||
// Fetch templates from 2 weeks before the most recent template
|
||||
sinceDate = new Date(mostRecentDate);
|
||||
sinceDate.setDate(sinceDate.getDate() - 14);
|
||||
logger.info(`Update mode: Fetching templates since ${sinceDate.toISOString().split('T')[0]} (2 weeks before most recent)`);
|
||||
} else {
|
||||
// No templates yet, fetch from last 2 weeks
|
||||
sinceDate = new Date();
|
||||
sinceDate.setDate(sinceDate.getDate() - 14);
|
||||
logger.info(`Update mode: No existing templates, fetching from last 2 weeks`);
|
||||
}
|
||||
} else {
|
||||
// Clear existing templates in rebuild mode
|
||||
this.repository.clearTemplates();
|
||||
logger.info('Rebuild mode: Cleared existing templates');
|
||||
}
|
||||
|
||||
|
||||
// Fetch template list
|
||||
logger.info(`Fetching template list from n8n.io (mode: ${mode})`);
|
||||
const templates = await fetcher.fetchTemplates((current, total) => {
|
||||
progressCallback?.('Fetching template list', current, total);
|
||||
});
|
||||
}, sinceDate);
|
||||
|
||||
logger.info(`Found ${templates.length} templates from last 12 months`);
|
||||
logger.info(`Found ${templates.length} templates matching date criteria`);
|
||||
|
||||
// Filter to only new templates if in update mode
|
||||
let templatesToFetch = templates;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Export n8n node type definitions and utilities
|
||||
export * from './node-types';
|
||||
|
||||
export interface MCPServerConfig {
|
||||
port: number;
|
||||
host: string;
|
||||
|
||||
220
src/types/node-types.ts
Normal file
220
src/types/node-types.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* TypeScript type definitions for n8n node parsing
|
||||
*
|
||||
* This file provides strong typing for node classes and instances,
|
||||
* preventing bugs like the v2.17.4 baseDescription issue where
|
||||
* TypeScript couldn't catch property name mistakes due to `any` types.
|
||||
*
|
||||
* @module types/node-types
|
||||
* @since 2.17.5
|
||||
*/
|
||||
|
||||
// Import n8n's official interfaces
|
||||
import type {
|
||||
IVersionedNodeType,
|
||||
INodeType,
|
||||
INodeTypeBaseDescription,
|
||||
INodeTypeDescription
|
||||
} from 'n8n-workflow';
|
||||
|
||||
/**
|
||||
* Represents a node class that can be either:
|
||||
* - A constructor function that returns INodeType
|
||||
* - A constructor function that returns IVersionedNodeType
|
||||
* - An already-instantiated node instance
|
||||
*
|
||||
* This covers all patterns we encounter when loading nodes from n8n packages.
|
||||
*/
|
||||
export type NodeClass =
|
||||
| (new () => INodeType)
|
||||
| (new () => IVersionedNodeType)
|
||||
| INodeType
|
||||
| IVersionedNodeType;
|
||||
|
||||
/**
|
||||
* Instance of a versioned node type with all properties accessible.
|
||||
*
|
||||
* This represents nodes that use n8n's VersionedNodeType pattern,
|
||||
* such as AI Agent, HTTP Request, Slack, etc.
|
||||
*
|
||||
* @property currentVersion - The computed current version (defaultVersion ?? max(nodeVersions))
|
||||
* @property description - Base description stored as 'description' (NOT 'baseDescription')
|
||||
* @property nodeVersions - Map of version numbers to INodeType implementations
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const aiAgent = new AIAgentNode() as VersionedNodeInstance;
|
||||
* console.log(aiAgent.currentVersion); // 2.2
|
||||
* console.log(aiAgent.description.defaultVersion); // 2.2
|
||||
* console.log(aiAgent.nodeVersions[1]); // INodeType for version 1
|
||||
* ```
|
||||
*/
|
||||
export interface VersionedNodeInstance extends IVersionedNodeType {
|
||||
currentVersion: number;
|
||||
description: INodeTypeBaseDescription;
|
||||
nodeVersions: {
|
||||
[version: number]: INodeType;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance of a regular (non-versioned) node type.
|
||||
*
|
||||
* This represents simple nodes that don't use versioning,
|
||||
* such as Edit Fields, Set, Code (v1), etc.
|
||||
*/
|
||||
export interface RegularNodeInstance extends INodeType {
|
||||
description: INodeTypeDescription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for any node instance (versioned or regular).
|
||||
*
|
||||
* Use this when you need to handle both types of nodes.
|
||||
*/
|
||||
export type NodeInstance = VersionedNodeInstance | RegularNodeInstance;
|
||||
|
||||
/**
|
||||
* Type guard to check if a node is a VersionedNodeType instance.
|
||||
*
|
||||
* This provides runtime type safety and enables TypeScript to narrow
|
||||
* the type within conditional blocks.
|
||||
*
|
||||
* @param node - The node instance to check
|
||||
* @returns True if node is a VersionedNodeInstance
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const instance = new nodeClass();
|
||||
* if (isVersionedNodeInstance(instance)) {
|
||||
* // TypeScript knows instance is VersionedNodeInstance here
|
||||
* console.log(instance.currentVersion);
|
||||
* console.log(instance.nodeVersions);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function isVersionedNodeInstance(node: any): node is VersionedNodeInstance {
|
||||
return (
|
||||
node !== null &&
|
||||
typeof node === 'object' &&
|
||||
'nodeVersions' in node &&
|
||||
'currentVersion' in node &&
|
||||
'description' in node &&
|
||||
typeof node.currentVersion === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a value is a VersionedNodeType class.
|
||||
*
|
||||
* This checks the constructor name pattern used by n8n's VersionedNodeType.
|
||||
*
|
||||
* @param nodeClass - The class or value to check
|
||||
* @returns True if nodeClass is a VersionedNodeType constructor
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* if (isVersionedNodeClass(nodeClass)) {
|
||||
* // It's a VersionedNodeType class
|
||||
* const instance = new nodeClass() as VersionedNodeInstance;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function isVersionedNodeClass(nodeClass: any): boolean {
|
||||
return (
|
||||
typeof nodeClass === 'function' &&
|
||||
nodeClass.prototype?.constructor?.name === 'VersionedNodeType'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely instantiate a node class with proper error handling.
|
||||
*
|
||||
* Some nodes require specific parameters or environment setup to instantiate.
|
||||
* This helper provides safe instantiation with fallback to null on error.
|
||||
*
|
||||
* @param nodeClass - The node class or instance to instantiate
|
||||
* @returns The instantiated node or null if instantiation fails
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const instance = instantiateNode(nodeClass);
|
||||
* if (instance) {
|
||||
* // Successfully instantiated
|
||||
* const version = isVersionedNodeInstance(instance)
|
||||
* ? instance.currentVersion
|
||||
* : instance.description.version;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function instantiateNode(nodeClass: NodeClass): NodeInstance | null {
|
||||
try {
|
||||
if (typeof nodeClass === 'function') {
|
||||
return new nodeClass();
|
||||
}
|
||||
// Already an instance
|
||||
return nodeClass;
|
||||
} catch (e) {
|
||||
// Some nodes require parameters to instantiate
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely get a node instance, handling both classes and instances.
|
||||
*
|
||||
* This is a non-throwing version that returns undefined on failure.
|
||||
*
|
||||
* @param nodeClass - The node class or instance
|
||||
* @returns The node instance or undefined
|
||||
*/
|
||||
export function getNodeInstance(nodeClass: NodeClass): NodeInstance | undefined {
|
||||
const instance = instantiateNode(nodeClass);
|
||||
return instance ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract description from a node class or instance.
|
||||
*
|
||||
* Handles both versioned and regular nodes, with fallback logic.
|
||||
*
|
||||
* @param nodeClass - The node class or instance
|
||||
* @returns The node description or empty object on failure
|
||||
*/
|
||||
export function getNodeDescription(
|
||||
nodeClass: NodeClass
|
||||
): INodeTypeBaseDescription | INodeTypeDescription {
|
||||
// Try to get description from instance first
|
||||
try {
|
||||
const instance = instantiateNode(nodeClass);
|
||||
|
||||
if (instance) {
|
||||
// For VersionedNodeType, description is the baseDescription
|
||||
if (isVersionedNodeInstance(instance)) {
|
||||
return instance.description;
|
||||
}
|
||||
// For regular nodes, description is the full INodeTypeDescription
|
||||
return instance.description;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore instantiation errors
|
||||
}
|
||||
|
||||
// Fallback to static properties
|
||||
if (typeof nodeClass === 'object' && 'description' in nodeClass) {
|
||||
return nodeClass.description;
|
||||
}
|
||||
|
||||
// Last resort: empty description
|
||||
return {
|
||||
displayName: '',
|
||||
name: '',
|
||||
group: [],
|
||||
description: '',
|
||||
version: 1,
|
||||
defaults: { name: '', color: '' },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: []
|
||||
} as any; // Type assertion needed for fallback case
|
||||
}
|
||||
@@ -4,6 +4,17 @@ import { SQLiteStorageService } from '../../src/services/sqlite-storage-service'
|
||||
import { NodeFactory } from '../factories/node-factory';
|
||||
import { PropertyDefinitionFactory } from '../factories/property-definition-factory';
|
||||
|
||||
/**
|
||||
* Database Query Performance Benchmarks
|
||||
*
|
||||
* NOTE: These benchmarks use MOCK DATA (500 artificial test nodes)
|
||||
* created with factories, not the real production database.
|
||||
*
|
||||
* This is useful for tracking database layer performance in isolation,
|
||||
* but may not reflect real-world performance characteristics.
|
||||
*
|
||||
* For end-to-end MCP tool performance with real data, see mcp-tools.bench.ts
|
||||
*/
|
||||
describe('Database Query Performance', () => {
|
||||
let repository: NodeRepository;
|
||||
let storage: SQLiteStorageService;
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
// Export all benchmark suites
|
||||
// Note: Some benchmarks are temporarily disabled due to API changes
|
||||
// export * from './node-loading.bench';
|
||||
export * from './database-queries.bench';
|
||||
// export * from './search-operations.bench';
|
||||
// export * from './validation-performance.bench';
|
||||
// export * from './mcp-tools.bench';
|
||||
export * from './mcp-tools.bench';
|
||||
169
tests/benchmarks/mcp-tools.bench.ts
Normal file
169
tests/benchmarks/mcp-tools.bench.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { NodeRepository } from '../../src/database/node-repository';
|
||||
import { createDatabaseAdapter } from '../../src/database/database-adapter';
|
||||
import { EnhancedConfigValidator } from '../../src/services/enhanced-config-validator';
|
||||
import { PropertyFilter } from '../../src/services/property-filter';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* MCP Tool Performance Benchmarks
|
||||
*
|
||||
* These benchmarks measure end-to-end performance of actual MCP tool operations
|
||||
* using the REAL production database (data/nodes.db with 525+ nodes).
|
||||
*
|
||||
* Unlike database-queries.bench.ts which uses mock data, these benchmarks
|
||||
* reflect what AI assistants actually experience when calling MCP tools,
|
||||
* making this the most meaningful performance metric for the system.
|
||||
*/
|
||||
describe('MCP Tool Performance (Production Database)', () => {
|
||||
let repository: NodeRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Use REAL production database
|
||||
const dbPath = path.join(__dirname, '../../data/nodes.db');
|
||||
const db = await createDatabaseAdapter(dbPath);
|
||||
repository = new NodeRepository(db);
|
||||
// Initialize similarity services for validation
|
||||
EnhancedConfigValidator.initializeSimilarityServices(repository);
|
||||
});
|
||||
|
||||
/**
|
||||
* search_nodes - Most frequently used tool for node discovery
|
||||
*
|
||||
* This measures:
|
||||
* - Database FTS5 full-text search
|
||||
* - Result filtering and ranking
|
||||
* - Response serialization
|
||||
*
|
||||
* Target: <20ms for common queries
|
||||
*/
|
||||
bench('search_nodes - common query (http)', async () => {
|
||||
await repository.searchNodes('http', 'OR', 20);
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('search_nodes - AI agent query (slack message)', async () => {
|
||||
await repository.searchNodes('slack send message', 'AND', 10);
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
/**
|
||||
* get_node_essentials - Fast retrieval of node configuration
|
||||
*
|
||||
* This measures:
|
||||
* - Database node lookup
|
||||
* - Property filtering (essentials only)
|
||||
* - Response formatting
|
||||
*
|
||||
* Target: <10ms for most nodes
|
||||
*/
|
||||
bench('get_node_essentials - HTTP Request node', async () => {
|
||||
const node = await repository.getNodeByType('n8n-nodes-base.httpRequest');
|
||||
if (node && node.properties) {
|
||||
PropertyFilter.getEssentials(node.properties, node.nodeType);
|
||||
}
|
||||
}, {
|
||||
iterations: 200,
|
||||
warmupIterations: 20,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('get_node_essentials - Slack node', async () => {
|
||||
const node = await repository.getNodeByType('n8n-nodes-base.slack');
|
||||
if (node && node.properties) {
|
||||
PropertyFilter.getEssentials(node.properties, node.nodeType);
|
||||
}
|
||||
}, {
|
||||
iterations: 200,
|
||||
warmupIterations: 20,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
/**
|
||||
* list_nodes - Initial exploration/listing
|
||||
*
|
||||
* This measures:
|
||||
* - Database query with pagination
|
||||
* - Result serialization
|
||||
* - Category filtering
|
||||
*
|
||||
* Target: <15ms for first page
|
||||
*/
|
||||
bench('list_nodes - first 50 nodes', async () => {
|
||||
await repository.getAllNodes(50);
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('list_nodes - AI tools only', async () => {
|
||||
await repository.getAIToolNodes();
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
/**
|
||||
* validate_node_operation - Configuration validation
|
||||
*
|
||||
* This measures:
|
||||
* - Schema lookup
|
||||
* - Validation logic execution
|
||||
* - Error message formatting
|
||||
*
|
||||
* Target: <15ms for simple validations
|
||||
*/
|
||||
bench('validate_node_operation - HTTP Request (minimal)', async () => {
|
||||
const node = await repository.getNodeByType('n8n-nodes-base.httpRequest');
|
||||
if (node && node.properties) {
|
||||
EnhancedConfigValidator.validateWithMode(
|
||||
'n8n-nodes-base.httpRequest',
|
||||
{},
|
||||
node.properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
}
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('validate_node_operation - HTTP Request (with params)', async () => {
|
||||
const node = await repository.getNodeByType('n8n-nodes-base.httpRequest');
|
||||
if (node && node.properties) {
|
||||
EnhancedConfigValidator.validateWithMode(
|
||||
'n8n-nodes-base.httpRequest',
|
||||
{
|
||||
requestMethod: 'GET',
|
||||
url: 'https://api.example.com',
|
||||
authentication: 'none'
|
||||
},
|
||||
node.properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
}
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
|
||||
/**
|
||||
* Sample benchmark to verify the setup works correctly
|
||||
*/
|
||||
describe('Sample Benchmarks', () => {
|
||||
bench('array sorting - small', () => {
|
||||
const arr = Array.from({ length: 100 }, () => Math.random());
|
||||
arr.sort((a, b) => a - b);
|
||||
}, {
|
||||
iterations: 1000,
|
||||
warmupIterations: 100
|
||||
});
|
||||
|
||||
bench('array sorting - large', () => {
|
||||
const arr = Array.from({ length: 10000 }, () => Math.random());
|
||||
arr.sort((a, b) => a - b);
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10
|
||||
});
|
||||
|
||||
bench('string concatenation', () => {
|
||||
let str = '';
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
str += 'a';
|
||||
}
|
||||
}, {
|
||||
iterations: 1000,
|
||||
warmupIterations: 100
|
||||
});
|
||||
|
||||
bench('object creation', () => {
|
||||
const objects = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
objects.push({
|
||||
id: i,
|
||||
name: `Object ${i}`,
|
||||
value: Math.random(),
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}, {
|
||||
iterations: 1000,
|
||||
warmupIterations: 100
|
||||
});
|
||||
});
|
||||
277
tests/integration/telemetry/docker-user-id-stability.test.ts
Normal file
277
tests/integration/telemetry/docker-user-id-stability.test.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TelemetryConfigManager } from '../../../src/telemetry/config-manager';
|
||||
import { existsSync, readFileSync, unlinkSync, rmSync } from 'fs';
|
||||
import { join, resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
/**
|
||||
* Integration tests for Docker user ID stability
|
||||
* Tests actual file system operations and environment detection
|
||||
*/
|
||||
describe('Docker User ID Stability - Integration Tests', () => {
|
||||
let manager: TelemetryConfigManager;
|
||||
const configPath = join(homedir(), '.n8n-mcp', 'telemetry.json');
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
// Clean up any existing config
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
unlinkSync(configPath);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
// Reset singleton
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
|
||||
// Reset environment
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore environment
|
||||
process.env = originalEnv;
|
||||
|
||||
// Clean up test config
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
unlinkSync(configPath);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('boot_id file reading', () => {
|
||||
it('should read boot_id from /proc/sys/kernel/random/boot_id if available', () => {
|
||||
const bootIdPath = '/proc/sys/kernel/random/boot_id';
|
||||
|
||||
// Skip test if not on Linux or boot_id not available
|
||||
if (!existsSync(bootIdPath)) {
|
||||
console.log('⚠️ Skipping boot_id test - not available on this system');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const bootId = readFileSync(bootIdPath, 'utf-8').trim();
|
||||
|
||||
// Verify it's a valid UUID
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
expect(bootId).toMatch(uuidRegex);
|
||||
expect(bootId).toHaveLength(36); // UUID with dashes
|
||||
} catch (error) {
|
||||
console.log('⚠️ boot_id exists but not readable:', error);
|
||||
}
|
||||
});
|
||||
|
||||
it('should generate stable user ID when boot_id is available in Docker', () => {
|
||||
const bootIdPath = '/proc/sys/kernel/random/boot_id';
|
||||
|
||||
// Skip if not in Docker environment or boot_id not available
|
||||
if (!existsSync(bootIdPath)) {
|
||||
console.log('⚠️ Skipping Docker boot_id test - not in Linux container');
|
||||
return;
|
||||
}
|
||||
|
||||
process.env.IS_DOCKER = 'true';
|
||||
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId1 = manager.getUserId();
|
||||
|
||||
// Reset singleton and get new instance
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId2 = manager.getUserId();
|
||||
|
||||
// Should be identical across recreations (boot_id is stable)
|
||||
expect(userId1).toBe(userId2);
|
||||
expect(userId1).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence across getInstance() calls', () => {
|
||||
it('should return same user ID across multiple getInstance() calls', () => {
|
||||
process.env.IS_DOCKER = 'true';
|
||||
|
||||
const manager1 = TelemetryConfigManager.getInstance();
|
||||
const userId1 = manager1.getUserId();
|
||||
|
||||
const manager2 = TelemetryConfigManager.getInstance();
|
||||
const userId2 = manager2.getUserId();
|
||||
|
||||
const manager3 = TelemetryConfigManager.getInstance();
|
||||
const userId3 = manager3.getUserId();
|
||||
|
||||
expect(userId1).toBe(userId2);
|
||||
expect(userId2).toBe(userId3);
|
||||
expect(manager1).toBe(manager2);
|
||||
expect(manager2).toBe(manager3);
|
||||
});
|
||||
|
||||
it('should persist user ID to disk and reload correctly', () => {
|
||||
process.env.IS_DOCKER = 'true';
|
||||
|
||||
// First instance - creates config
|
||||
const manager1 = TelemetryConfigManager.getInstance();
|
||||
const userId1 = manager1.getUserId();
|
||||
|
||||
// Load config to trigger save
|
||||
manager1.loadConfig();
|
||||
|
||||
// Wait a bit for file write
|
||||
expect(existsSync(configPath)).toBe(true);
|
||||
|
||||
// Reset singleton
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
|
||||
// Second instance - loads from disk
|
||||
const manager2 = TelemetryConfigManager.getInstance();
|
||||
const userId2 = manager2.getUserId();
|
||||
|
||||
expect(userId1).toBe(userId2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Docker vs non-Docker detection', () => {
|
||||
it('should detect Docker environment via IS_DOCKER=true', () => {
|
||||
process.env.IS_DOCKER = 'true';
|
||||
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const config = manager.loadConfig();
|
||||
|
||||
// In Docker, should use boot_id-based method
|
||||
expect(config.userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
|
||||
it('should use file-based method for non-Docker local installations', () => {
|
||||
// Ensure no Docker/cloud environment variables
|
||||
delete process.env.IS_DOCKER;
|
||||
delete process.env.RAILWAY_ENVIRONMENT;
|
||||
delete process.env.RENDER;
|
||||
delete process.env.FLY_APP_NAME;
|
||||
delete process.env.HEROKU_APP_NAME;
|
||||
delete process.env.AWS_EXECUTION_ENV;
|
||||
delete process.env.KUBERNETES_SERVICE_HOST;
|
||||
delete process.env.GOOGLE_CLOUD_PROJECT;
|
||||
delete process.env.AZURE_FUNCTIONS_ENVIRONMENT;
|
||||
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const config = manager.loadConfig();
|
||||
|
||||
// Should generate valid user ID
|
||||
expect(config.userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
|
||||
// Should persist to file for local installations
|
||||
expect(existsSync(configPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('environment variable detection', () => {
|
||||
it('should detect Railway cloud environment', () => {
|
||||
process.env.RAILWAY_ENVIRONMENT = 'production';
|
||||
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
// Should use Docker/cloud method (boot_id-based)
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
|
||||
it('should detect Render cloud environment', () => {
|
||||
process.env.RENDER = 'true';
|
||||
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
|
||||
it('should detect Fly.io cloud environment', () => {
|
||||
process.env.FLY_APP_NAME = 'n8n-mcp-app';
|
||||
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
|
||||
it('should detect Heroku cloud environment', () => {
|
||||
process.env.HEROKU_APP_NAME = 'n8n-mcp-app';
|
||||
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
|
||||
it('should detect AWS cloud environment', () => {
|
||||
process.env.AWS_EXECUTION_ENV = 'AWS_ECS_FARGATE';
|
||||
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
|
||||
it('should detect Kubernetes environment', () => {
|
||||
process.env.KUBERNETES_SERVICE_HOST = '10.0.0.1';
|
||||
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
|
||||
it('should detect Google Cloud environment', () => {
|
||||
process.env.GOOGLE_CLOUD_PROJECT = 'n8n-mcp-project';
|
||||
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
|
||||
it('should detect Azure cloud environment', () => {
|
||||
process.env.AZURE_FUNCTIONS_ENVIRONMENT = 'production';
|
||||
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback chain behavior', () => {
|
||||
it('should use combined fingerprint fallback when boot_id unavailable', () => {
|
||||
// Set Docker environment but boot_id won't be available on macOS
|
||||
process.env.IS_DOCKER = 'true';
|
||||
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
// Should still generate valid user ID via fallback
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
expect(userId).toHaveLength(16);
|
||||
});
|
||||
|
||||
it('should generate consistent generic Docker ID when all else fails', () => {
|
||||
// Set Docker but no boot_id or /proc signals available (e.g., macOS)
|
||||
process.env.IS_DOCKER = 'true';
|
||||
|
||||
const manager1 = TelemetryConfigManager.getInstance();
|
||||
const userId1 = manager1.getUserId();
|
||||
|
||||
// Reset singleton
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
|
||||
const manager2 = TelemetryConfigManager.getInstance();
|
||||
const userId2 = manager2.getUserId();
|
||||
|
||||
// Generic Docker ID should be consistent across calls
|
||||
expect(userId1).toBe(userId2);
|
||||
expect(userId1).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,7 @@ describe('NodeParser - Output Extraction', () => {
|
||||
description = nodeDescription;
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toEqual(outputs);
|
||||
expect(result.outputNames).toBeUndefined();
|
||||
@@ -60,7 +60,7 @@ describe('NodeParser - Output Extraction', () => {
|
||||
description = nodeDescription;
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputNames).toEqual(outputNames);
|
||||
expect(result.outputs).toBeUndefined();
|
||||
@@ -84,7 +84,7 @@ describe('NodeParser - Output Extraction', () => {
|
||||
description = nodeDescription;
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toEqual(outputs);
|
||||
expect(result.outputNames).toEqual(outputNames);
|
||||
@@ -103,7 +103,7 @@ describe('NodeParser - Output Extraction', () => {
|
||||
description = nodeDescription;
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toEqual([singleOutput]);
|
||||
});
|
||||
@@ -119,7 +119,7 @@ describe('NodeParser - Output Extraction', () => {
|
||||
description = nodeDescription;
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputNames).toEqual(['main']);
|
||||
});
|
||||
@@ -152,7 +152,7 @@ describe('NodeParser - Output Extraction', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
// Should get outputs from latest version (2)
|
||||
expect(result.outputs).toEqual(versionedOutputs);
|
||||
@@ -172,7 +172,7 @@ describe('NodeParser - Output Extraction', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toBeUndefined();
|
||||
expect(result.outputNames).toBeUndefined();
|
||||
@@ -189,7 +189,7 @@ describe('NodeParser - Output Extraction', () => {
|
||||
description = nodeDescription;
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toBeUndefined();
|
||||
expect(result.outputNames).toBeUndefined();
|
||||
@@ -229,7 +229,7 @@ describe('NodeParser - Output Extraction', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
// Should use latest version (3)
|
||||
expect(result.outputs).toEqual([
|
||||
@@ -259,7 +259,7 @@ describe('NodeParser - Output Extraction', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toEqual(baseOutputs);
|
||||
});
|
||||
@@ -279,7 +279,7 @@ describe('NodeParser - Output Extraction', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toEqual(ifOutputs);
|
||||
expect(result.outputNames).toEqual(['true', 'false']);
|
||||
@@ -300,7 +300,7 @@ describe('NodeParser - Output Extraction', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toEqual(splitInBatchesOutputs);
|
||||
expect(result.outputNames).toEqual(['done', 'loop']);
|
||||
@@ -331,7 +331,7 @@ describe('NodeParser - Output Extraction', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toEqual(switchOutputs);
|
||||
expect(result.outputNames).toEqual(['0', '1', '2', 'fallback']);
|
||||
@@ -347,7 +347,7 @@ describe('NodeParser - Output Extraction', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toEqual([]);
|
||||
expect(result.outputNames).toEqual([]);
|
||||
@@ -369,7 +369,7 @@ describe('NodeParser - Output Extraction', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toEqual(outputs);
|
||||
expect(result.outputNames).toEqual(outputNames);
|
||||
@@ -405,7 +405,7 @@ describe('NodeParser - Output Extraction', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toHaveLength(2);
|
||||
expect(result.outputs).toBeDefined();
|
||||
@@ -442,7 +442,7 @@ describe('NodeParser - Output Extraction', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toHaveLength(2);
|
||||
expect(result.outputs).toBeDefined();
|
||||
@@ -464,7 +464,7 @@ describe('NodeParser - Output Extraction', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.outputs).toBeUndefined();
|
||||
expect(result.outputNames).toBeUndefined();
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('NodeParser', () => {
|
||||
mockPropertyExtractor.extractProperties.mockReturnValue(nodeDefinition.properties);
|
||||
mockPropertyExtractor.extractCredentials.mockReturnValue(nodeDefinition.credentials);
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
style: 'programmatic',
|
||||
@@ -70,7 +70,7 @@ describe('NodeParser', () => {
|
||||
const nodeDefinition = declarativeNodeFactory.build();
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.style).toBe('declarative');
|
||||
expect(result.nodeType).toBe(`nodes-base.${nodeDefinition.name}`);
|
||||
@@ -82,7 +82,7 @@ describe('NodeParser', () => {
|
||||
});
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.nodeType).toBe('nodes-base.slack');
|
||||
});
|
||||
@@ -91,7 +91,7 @@ describe('NodeParser', () => {
|
||||
const nodeDefinition = triggerNodeFactory.build();
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.isTrigger).toBe(true);
|
||||
});
|
||||
@@ -100,7 +100,7 @@ describe('NodeParser', () => {
|
||||
const nodeDefinition = webhookNodeFactory.build();
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.isWebhook).toBe(true);
|
||||
});
|
||||
@@ -111,7 +111,7 @@ describe('NodeParser', () => {
|
||||
|
||||
mockPropertyExtractor.detectAIToolCapability.mockReturnValue(true);
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.isAITool).toBe(true);
|
||||
});
|
||||
@@ -137,7 +137,7 @@ describe('NodeParser', () => {
|
||||
propertyFactory.build()
|
||||
]);
|
||||
|
||||
const result = parser.parse(VersionedNodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(VersionedNodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.isVersioned).toBe(true);
|
||||
expect(result.version).toBe('2');
|
||||
@@ -151,7 +151,7 @@ describe('NodeParser', () => {
|
||||
baseDescription = versionedDef.baseDescription;
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.isVersioned).toBe(true);
|
||||
expect(result.version).toBe('2');
|
||||
@@ -163,7 +163,7 @@ describe('NodeParser', () => {
|
||||
});
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.isVersioned).toBe(true);
|
||||
expect(result.version).toBe('2'); // Should return max version
|
||||
@@ -173,7 +173,7 @@ describe('NodeParser', () => {
|
||||
const nodeDefinition = malformedNodeFactory.build();
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
expect(() => parser.parse(NodeClass, 'n8n-nodes-base')).toThrow('Node is missing name property');
|
||||
expect(() => parser.parse(NodeClass as any, 'n8n-nodes-base')).toThrow('Node is missing name property');
|
||||
});
|
||||
|
||||
it('should use static description when instantiation fails', () => {
|
||||
@@ -184,7 +184,7 @@ describe('NodeParser', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.displayName).toBe(NodeClass.description.displayName);
|
||||
});
|
||||
@@ -205,7 +205,7 @@ describe('NodeParser', () => {
|
||||
} as any);
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.category).toBe(expected);
|
||||
});
|
||||
@@ -217,7 +217,7 @@ describe('NodeParser', () => {
|
||||
});
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.isTrigger).toBe(true);
|
||||
});
|
||||
@@ -228,7 +228,7 @@ describe('NodeParser', () => {
|
||||
});
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.isTrigger).toBe(true);
|
||||
});
|
||||
@@ -239,7 +239,7 @@ describe('NodeParser', () => {
|
||||
});
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.isTrigger).toBe(true);
|
||||
});
|
||||
@@ -250,7 +250,7 @@ describe('NodeParser', () => {
|
||||
});
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.isWebhook).toBe(true);
|
||||
});
|
||||
@@ -262,8 +262,8 @@ describe('NodeParser', () => {
|
||||
};
|
||||
|
||||
mockPropertyExtractor.extractProperties.mockReturnValue(nodeDefinition.properties);
|
||||
|
||||
const result = parser.parse(nodeInstance, 'n8n-nodes-base');
|
||||
|
||||
const result = parser.parse(nodeInstance as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.displayName).toBe(nodeDefinition.displayName);
|
||||
});
|
||||
@@ -279,27 +279,71 @@ describe('NodeParser', () => {
|
||||
];
|
||||
|
||||
testCases.forEach(({ packageName, expectedPrefix }) => {
|
||||
const result = parser.parse(NodeClass, packageName);
|
||||
const result = parser.parse(NodeClass as any, packageName);
|
||||
expect(result.nodeType).toBe(`${expectedPrefix}.${nodeDefinition.name}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('version extraction', () => {
|
||||
it('should extract version from baseDescription.defaultVersion', () => {
|
||||
it('should prioritize currentVersion over description.defaultVersion', () => {
|
||||
const NodeClass = class {
|
||||
baseDescription = {
|
||||
currentVersion = 2.2; // Should be returned
|
||||
description = {
|
||||
name: 'AI Agent',
|
||||
displayName: 'AI Agent',
|
||||
defaultVersion: 3 // Should be ignored when currentVersion exists
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.version).toBe('2.2');
|
||||
});
|
||||
|
||||
it('should extract version from description.defaultVersion', () => {
|
||||
const NodeClass = class {
|
||||
description = {
|
||||
name: 'test',
|
||||
displayName: 'Test',
|
||||
defaultVersion: 3
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
|
||||
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.version).toBe('3');
|
||||
});
|
||||
|
||||
it('should handle currentVersion = 0 correctly', () => {
|
||||
const NodeClass = class {
|
||||
currentVersion = 0; // Edge case: version 0 should be valid
|
||||
description = {
|
||||
name: 'test',
|
||||
displayName: 'Test',
|
||||
defaultVersion: 5 // Should be ignored
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.version).toBe('0');
|
||||
});
|
||||
|
||||
it('should NOT extract version from non-existent baseDescription (legacy bug)', () => {
|
||||
const NodeClass = class {
|
||||
baseDescription = { // This property doesn't exist on VersionedNodeType!
|
||||
name: 'test',
|
||||
displayName: 'Test',
|
||||
defaultVersion: 3
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.version).toBe('1'); // Should fallback to default
|
||||
});
|
||||
|
||||
it('should extract version from nodeVersions keys', () => {
|
||||
const NodeClass = class {
|
||||
description = { name: 'test', displayName: 'Test' };
|
||||
@@ -310,7 +354,7 @@ describe('NodeParser', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.version).toBe('3');
|
||||
});
|
||||
@@ -328,7 +372,7 @@ describe('NodeParser', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.version).toBe('4');
|
||||
});
|
||||
@@ -339,7 +383,7 @@ describe('NodeParser', () => {
|
||||
});
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.version).toBe('2');
|
||||
});
|
||||
@@ -350,7 +394,7 @@ describe('NodeParser', () => {
|
||||
});
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.version).toBe('1.5');
|
||||
});
|
||||
@@ -360,7 +404,7 @@ describe('NodeParser', () => {
|
||||
delete (nodeDefinition as any).version;
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.version).toBe('1');
|
||||
});
|
||||
@@ -373,7 +417,7 @@ describe('NodeParser', () => {
|
||||
nodeVersions = { 1: {}, 2: {} };
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.isVersioned).toBe(true);
|
||||
});
|
||||
@@ -387,7 +431,7 @@ describe('NodeParser', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.isVersioned).toBe(true);
|
||||
});
|
||||
@@ -401,7 +445,7 @@ describe('NodeParser', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.isVersioned).toBe(true);
|
||||
});
|
||||
@@ -412,7 +456,7 @@ describe('NodeParser', () => {
|
||||
});
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.isVersioned).toBe(false);
|
||||
});
|
||||
@@ -424,7 +468,7 @@ describe('NodeParser', () => {
|
||||
description = null;
|
||||
};
|
||||
|
||||
expect(() => parser.parse(NodeClass, 'n8n-nodes-base')).toThrow();
|
||||
expect(() => parser.parse(NodeClass as any, 'n8n-nodes-base')).toThrow();
|
||||
});
|
||||
|
||||
it('should handle empty routing object for declarative nodes', () => {
|
||||
@@ -433,7 +477,7 @@ describe('NodeParser', () => {
|
||||
});
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.style).toBe('declarative');
|
||||
});
|
||||
@@ -459,7 +503,7 @@ describe('NodeParser', () => {
|
||||
value: 'VersionedNodeType'
|
||||
});
|
||||
|
||||
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||
const result = parser.parse(NodeClass as any, 'n8n-nodes-base');
|
||||
|
||||
expect(result.isVersioned).toBe(true);
|
||||
expect(result.version).toBe('3');
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('PropertyExtractor', () => {
|
||||
const nodeDefinition = programmaticNodeFactory.build();
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const properties = extractor.extractProperties(NodeClass);
|
||||
const properties = extractor.extractProperties(NodeClass as any);
|
||||
|
||||
expect(properties).toHaveLength(nodeDefinition.properties.length);
|
||||
expect(properties).toEqual(expect.arrayContaining(
|
||||
@@ -50,7 +50,7 @@ describe('PropertyExtractor', () => {
|
||||
baseDescription = versionedDef.baseDescription;
|
||||
};
|
||||
|
||||
const properties = extractor.extractProperties(NodeClass);
|
||||
const properties = extractor.extractProperties(NodeClass as any);
|
||||
|
||||
// Should get properties from version 2 (latest)
|
||||
expect(properties).toHaveLength(versionedDef.nodeVersions![2].description.properties.length);
|
||||
@@ -78,7 +78,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const properties = extractor.extractProperties(NodeClass);
|
||||
const properties = extractor.extractProperties(NodeClass as any);
|
||||
|
||||
expect(properties).toHaveLength(2);
|
||||
expect(properties[0].name).toBe('v2prop1');
|
||||
@@ -108,7 +108,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const properties = extractor.extractProperties(NodeClass);
|
||||
const properties = extractor.extractProperties(NodeClass as any);
|
||||
|
||||
expect(properties[0]).toEqual({
|
||||
displayName: 'Field 1',
|
||||
@@ -135,7 +135,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const properties = extractor.extractProperties(NodeClass);
|
||||
const properties = extractor.extractProperties(NodeClass as any);
|
||||
|
||||
expect(properties).toEqual([]);
|
||||
});
|
||||
@@ -151,7 +151,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const properties = extractor.extractProperties(NodeClass);
|
||||
const properties = extractor.extractProperties(NodeClass as any);
|
||||
|
||||
expect(properties).toHaveLength(1); // Should get static description property
|
||||
});
|
||||
@@ -165,7 +165,7 @@ describe('PropertyExtractor', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const properties = extractor.extractProperties(NodeClass);
|
||||
const properties = extractor.extractProperties(NodeClass as any);
|
||||
|
||||
expect(properties).toHaveLength(1);
|
||||
expect(properties[0].name).toBe('baseProp');
|
||||
@@ -180,7 +180,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const properties = extractor.extractProperties(NodeClass);
|
||||
const properties = extractor.extractProperties(NodeClass as any);
|
||||
|
||||
expect(properties).toHaveLength(1);
|
||||
expect(properties[0].type).toBe('collection');
|
||||
@@ -193,9 +193,9 @@ describe('PropertyExtractor', () => {
|
||||
properties: [propertyFactory.build()]
|
||||
}
|
||||
};
|
||||
|
||||
const properties = extractor.extractProperties(nodeInstance);
|
||||
|
||||
|
||||
const properties = extractor.extractProperties(nodeInstance as any);
|
||||
|
||||
expect(properties).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -205,7 +205,7 @@ describe('PropertyExtractor', () => {
|
||||
const nodeDefinition = declarativeNodeFactory.build();
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const operations = extractor.extractOperations(NodeClass);
|
||||
const operations = extractor.extractOperations(NodeClass as any);
|
||||
|
||||
// Declarative node has 2 resources with 2 operations each = 4 total
|
||||
expect(operations.length).toBe(4);
|
||||
@@ -235,7 +235,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const operations = extractor.extractOperations(NodeClass);
|
||||
const operations = extractor.extractOperations(NodeClass as any);
|
||||
|
||||
expect(operations.length).toBe(operationProp.options!.length);
|
||||
operations.forEach((op, idx) => {
|
||||
@@ -261,7 +261,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const operations = extractor.extractOperations(NodeClass);
|
||||
const operations = extractor.extractOperations(NodeClass as any);
|
||||
|
||||
// routing.operations is not currently extracted by the property extractor
|
||||
// It only extracts from routing.request structure
|
||||
@@ -292,7 +292,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const operations = extractor.extractOperations(NodeClass);
|
||||
const operations = extractor.extractOperations(NodeClass as any);
|
||||
|
||||
// PropertyExtractor only extracts operations, not resources
|
||||
// It should find the operation property and extract its options
|
||||
@@ -317,7 +317,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const operations = extractor.extractOperations(NodeClass);
|
||||
const operations = extractor.extractOperations(NodeClass as any);
|
||||
|
||||
expect(operations).toEqual([]);
|
||||
});
|
||||
@@ -353,7 +353,7 @@ describe('PropertyExtractor', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const operations = extractor.extractOperations(NodeClass);
|
||||
const operations = extractor.extractOperations(NodeClass as any);
|
||||
|
||||
expect(operations).toHaveLength(1);
|
||||
expect(operations[0]).toMatchObject({
|
||||
@@ -382,7 +382,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const operations = extractor.extractOperations(NodeClass);
|
||||
const operations = extractor.extractOperations(NodeClass as any);
|
||||
|
||||
expect(operations).toHaveLength(2);
|
||||
expect(operations[0].operation).toBe('send');
|
||||
@@ -398,7 +398,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const isAITool = extractor.detectAIToolCapability(NodeClass);
|
||||
const isAITool = extractor.detectAIToolCapability(NodeClass as any);
|
||||
|
||||
expect(isAITool).toBe(true);
|
||||
});
|
||||
@@ -414,7 +414,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const isAITool = extractor.detectAIToolCapability(NodeClass);
|
||||
const isAITool = extractor.detectAIToolCapability(NodeClass as any);
|
||||
|
||||
expect(isAITool).toBe(true);
|
||||
});
|
||||
@@ -431,7 +431,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const isAITool = extractor.detectAIToolCapability(NodeClass);
|
||||
const isAITool = extractor.detectAIToolCapability(NodeClass as any);
|
||||
|
||||
expect(isAITool).toBe(true);
|
||||
});
|
||||
@@ -444,7 +444,7 @@ describe('PropertyExtractor', () => {
|
||||
description: { name }
|
||||
});
|
||||
|
||||
const isAITool = extractor.detectAIToolCapability(NodeClass);
|
||||
const isAITool = extractor.detectAIToolCapability(NodeClass as any);
|
||||
|
||||
expect(isAITool).toBe(true);
|
||||
});
|
||||
@@ -458,7 +458,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const isAITool = extractor.detectAIToolCapability(NodeClass);
|
||||
const isAITool = extractor.detectAIToolCapability(NodeClass as any);
|
||||
|
||||
expect(isAITool).toBe(false);
|
||||
});
|
||||
@@ -466,7 +466,7 @@ describe('PropertyExtractor', () => {
|
||||
it('should return false when node has no description', () => {
|
||||
const NodeClass = class {};
|
||||
|
||||
const isAITool = extractor.detectAIToolCapability(NodeClass);
|
||||
const isAITool = extractor.detectAIToolCapability(NodeClass as any);
|
||||
|
||||
expect(isAITool).toBe(false);
|
||||
});
|
||||
@@ -486,7 +486,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const extracted = extractor.extractCredentials(NodeClass);
|
||||
const extracted = extractor.extractCredentials(NodeClass as any);
|
||||
|
||||
expect(extracted).toEqual(credentials);
|
||||
});
|
||||
@@ -510,7 +510,7 @@ describe('PropertyExtractor', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const credentials = extractor.extractCredentials(NodeClass);
|
||||
const credentials = extractor.extractCredentials(NodeClass as any);
|
||||
|
||||
expect(credentials).toHaveLength(2);
|
||||
expect(credentials[0].name).toBe('oauth2');
|
||||
@@ -525,7 +525,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const credentials = extractor.extractCredentials(NodeClass);
|
||||
const credentials = extractor.extractCredentials(NodeClass as any);
|
||||
|
||||
expect(credentials).toEqual([]);
|
||||
});
|
||||
@@ -537,7 +537,7 @@ describe('PropertyExtractor', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const credentials = extractor.extractCredentials(NodeClass);
|
||||
const credentials = extractor.extractCredentials(NodeClass as any);
|
||||
|
||||
expect(credentials).toHaveLength(1);
|
||||
expect(credentials[0].name).toBe('token');
|
||||
@@ -554,7 +554,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const credentials = extractor.extractCredentials(NodeClass);
|
||||
const credentials = extractor.extractCredentials(NodeClass as any);
|
||||
|
||||
expect(credentials).toHaveLength(1);
|
||||
expect(credentials[0].name).toBe('jwt');
|
||||
@@ -567,7 +567,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const credentials = extractor.extractCredentials(NodeClass);
|
||||
const credentials = extractor.extractCredentials(NodeClass as any);
|
||||
|
||||
expect(credentials).toEqual([]);
|
||||
});
|
||||
@@ -605,7 +605,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const properties = extractor.extractProperties(NodeClass);
|
||||
const properties = extractor.extractProperties(NodeClass as any);
|
||||
|
||||
expect(properties).toHaveLength(1);
|
||||
expect(properties[0].name).toBe('deepOptions');
|
||||
@@ -627,7 +627,7 @@ describe('PropertyExtractor', () => {
|
||||
};
|
||||
|
||||
// Should not throw or hang
|
||||
const properties = extractor.extractProperties(NodeClass);
|
||||
const properties = extractor.extractProperties(NodeClass as any);
|
||||
|
||||
expect(properties).toBeDefined();
|
||||
});
|
||||
@@ -652,7 +652,7 @@ describe('PropertyExtractor', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const operations = extractor.extractOperations(NodeClass);
|
||||
const operations = extractor.extractOperations(NodeClass as any);
|
||||
|
||||
// Should extract from all sources
|
||||
expect(operations.length).toBeGreaterThan(1);
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('SimpleParser', () => {
|
||||
const nodeDefinition = programmaticNodeFactory.build();
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
style: 'programmatic',
|
||||
@@ -58,7 +58,7 @@ describe('SimpleParser', () => {
|
||||
} as any;
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.style).toBe('declarative');
|
||||
expect(result.operations.length).toBeGreaterThan(0);
|
||||
@@ -68,7 +68,7 @@ describe('SimpleParser', () => {
|
||||
const nodeDefinition = triggerNodeFactory.build();
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.isTrigger).toBe(true);
|
||||
});
|
||||
@@ -77,7 +77,7 @@ describe('SimpleParser', () => {
|
||||
const nodeDefinition = webhookNodeFactory.build();
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.isWebhook).toBe(true);
|
||||
});
|
||||
@@ -92,7 +92,7 @@ describe('SimpleParser', () => {
|
||||
} as any;
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.isAITool).toBe(true);
|
||||
});
|
||||
@@ -112,7 +112,7 @@ describe('SimpleParser', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = parser.parse(VersionedNodeClass);
|
||||
const result = parser.parse(VersionedNodeClass as any);
|
||||
|
||||
expect(result.isVersioned).toBe(true);
|
||||
expect(result.nodeType).toBe(versionedDef.baseDescription!.name);
|
||||
@@ -147,7 +147,7 @@ describe('SimpleParser', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = parser.parse(VersionedNodeClass);
|
||||
const result = parser.parse(VersionedNodeClass as any);
|
||||
|
||||
// Should merge baseDescription with version description
|
||||
expect(result.nodeType).toBe('mergedNode'); // From base
|
||||
@@ -159,7 +159,7 @@ describe('SimpleParser', () => {
|
||||
const nodeDefinition = malformedNodeFactory.build();
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
expect(() => parser.parse(NodeClass)).toThrow('Node is missing name property');
|
||||
expect(() => parser.parse(NodeClass as any)).toThrow('Node is missing name property');
|
||||
});
|
||||
|
||||
it('should handle nodes that fail to instantiate', () => {
|
||||
@@ -169,7 +169,7 @@ describe('SimpleParser', () => {
|
||||
}
|
||||
};
|
||||
|
||||
expect(() => parser.parse(NodeClass)).toThrow('Node is missing name property');
|
||||
expect(() => parser.parse(NodeClass as any)).toThrow('Node is missing name property');
|
||||
});
|
||||
|
||||
it('should handle static description property', () => {
|
||||
@@ -180,7 +180,7 @@ describe('SimpleParser', () => {
|
||||
|
||||
// Since it can't instantiate and has no static description accessible,
|
||||
// it should throw for missing name
|
||||
expect(() => parser.parse(NodeClass)).toThrow();
|
||||
expect(() => parser.parse(NodeClass as any)).toThrow();
|
||||
});
|
||||
|
||||
it('should handle instance-based nodes', () => {
|
||||
@@ -189,7 +189,7 @@ describe('SimpleParser', () => {
|
||||
description: nodeDefinition
|
||||
};
|
||||
|
||||
const result = parser.parse(nodeInstance);
|
||||
const result = parser.parse(nodeInstance as any);
|
||||
|
||||
expect(result.displayName).toBe(nodeDefinition.displayName);
|
||||
});
|
||||
@@ -199,7 +199,7 @@ describe('SimpleParser', () => {
|
||||
delete (nodeDefinition as any).displayName;
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.displayName).toBe(nodeDefinition.name);
|
||||
});
|
||||
@@ -233,7 +233,7 @@ describe('SimpleParser', () => {
|
||||
};
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.category).toBe(expected);
|
||||
});
|
||||
@@ -247,7 +247,7 @@ describe('SimpleParser', () => {
|
||||
});
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.isTrigger).toBe(true);
|
||||
});
|
||||
@@ -258,7 +258,7 @@ describe('SimpleParser', () => {
|
||||
});
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.isTrigger).toBe(true);
|
||||
});
|
||||
@@ -269,7 +269,7 @@ describe('SimpleParser', () => {
|
||||
});
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.isTrigger).toBe(true);
|
||||
});
|
||||
@@ -280,7 +280,7 @@ describe('SimpleParser', () => {
|
||||
});
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.isTrigger).toBe(true);
|
||||
});
|
||||
@@ -291,7 +291,7 @@ describe('SimpleParser', () => {
|
||||
});
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.isTrigger).toBe(true);
|
||||
});
|
||||
@@ -309,7 +309,7 @@ describe('SimpleParser', () => {
|
||||
};
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
// Should have resource operations
|
||||
const resourceOps = result.operations.filter(op => op.resource);
|
||||
@@ -335,7 +335,7 @@ describe('SimpleParser', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.operations).toHaveLength(4);
|
||||
expect(result.operations).toEqual(expect.arrayContaining([
|
||||
@@ -355,7 +355,7 @@ describe('SimpleParser', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
const resourceOps = result.operations.filter(op => op.type === 'resource');
|
||||
expect(resourceOps).toHaveLength(resourceProp.options!.length);
|
||||
@@ -377,7 +377,7 @@ describe('SimpleParser', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
const operationOps = result.operations.filter(op => op.type === 'operation');
|
||||
expect(operationOps).toHaveLength(operationProp.options!.length);
|
||||
@@ -407,7 +407,7 @@ describe('SimpleParser', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
const operationOps = result.operations.filter(op => op.type === 'operation');
|
||||
expect(operationOps[0].resources).toEqual(['user', 'post', 'comment']);
|
||||
@@ -434,7 +434,7 @@ describe('SimpleParser', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
const operationOps = result.operations.filter(op => op.type === 'operation');
|
||||
expect(operationOps[0].resources).toEqual(['user']);
|
||||
@@ -442,10 +442,38 @@ describe('SimpleParser', () => {
|
||||
});
|
||||
|
||||
describe('version extraction', () => {
|
||||
it('should extract version from baseDescription.defaultVersion', () => {
|
||||
// Simple parser needs a proper versioned node structure
|
||||
it('should prioritize currentVersion over description.defaultVersion', () => {
|
||||
const NodeClass = class {
|
||||
baseDescription = {
|
||||
currentVersion = 2.2; // Should be returned
|
||||
description = {
|
||||
name: 'test',
|
||||
displayName: 'Test',
|
||||
defaultVersion: 3 // Should be ignored when currentVersion exists
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass as any);
|
||||
expect(result.version).toBe('2.2');
|
||||
});
|
||||
|
||||
it('should extract version from description.defaultVersion', () => {
|
||||
const NodeClass = class {
|
||||
description = {
|
||||
name: 'test',
|
||||
displayName: 'Test',
|
||||
defaultVersion: 3
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass as any);
|
||||
expect(result.version).toBe('3');
|
||||
});
|
||||
|
||||
it('should NOT extract version from non-existent baseDescription (legacy bug)', () => {
|
||||
// This test verifies the bug fix from v2.17.4
|
||||
// baseDescription.defaultVersion doesn't exist on VersionedNodeType instances
|
||||
const NodeClass = class {
|
||||
baseDescription = { // This property doesn't exist on VersionedNodeType!
|
||||
name: 'test',
|
||||
displayName: 'Test',
|
||||
defaultVersion: 3
|
||||
@@ -458,10 +486,11 @@ describe('SimpleParser', () => {
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
|
||||
expect(result.version).toBe('3');
|
||||
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
// Should fallback to default version '1' since baseDescription.defaultVersion doesn't exist
|
||||
expect(result.version).toBe('1');
|
||||
});
|
||||
|
||||
it('should extract version from description.version', () => {
|
||||
@@ -473,7 +502,7 @@ describe('SimpleParser', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.version).toBe('2');
|
||||
});
|
||||
@@ -485,7 +514,7 @@ describe('SimpleParser', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.version).toBe('1');
|
||||
});
|
||||
@@ -509,7 +538,7 @@ describe('SimpleParser', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.isVersioned).toBe(true);
|
||||
});
|
||||
@@ -522,7 +551,7 @@ describe('SimpleParser', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.isVersioned).toBe(true);
|
||||
});
|
||||
@@ -535,7 +564,7 @@ describe('SimpleParser', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.isVersioned).toBe(true);
|
||||
});
|
||||
@@ -548,7 +577,7 @@ describe('SimpleParser', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.isVersioned).toBe(true);
|
||||
});
|
||||
@@ -563,7 +592,7 @@ describe('SimpleParser', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.style).toBe('declarative');
|
||||
expect(result.operations).toEqual([]);
|
||||
@@ -576,7 +605,7 @@ describe('SimpleParser', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.properties).toEqual([]);
|
||||
});
|
||||
@@ -586,7 +615,7 @@ describe('SimpleParser', () => {
|
||||
delete (nodeDefinition as any).credentials;
|
||||
const NodeClass = nodeClassFactory.build({ description: nodeDefinition });
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.credentials).toEqual([]);
|
||||
});
|
||||
@@ -600,7 +629,7 @@ describe('SimpleParser', () => {
|
||||
};
|
||||
};
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.nodeType).toBe('baseNode');
|
||||
expect(result.displayName).toBe('Base Node');
|
||||
@@ -624,7 +653,7 @@ describe('SimpleParser', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
expect(result.operations).toEqual([]);
|
||||
});
|
||||
@@ -649,7 +678,7 @@ describe('SimpleParser', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const result = parser.parse(NodeClass);
|
||||
const result = parser.parse(NodeClass as any);
|
||||
|
||||
// Should handle missing names gracefully
|
||||
expect(result.operations).toHaveLength(2);
|
||||
|
||||
@@ -439,4 +439,335 @@ describe('ConfigValidator - Basic Validation', () => {
|
||||
expect(result.suggestions.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resourceLocator validation', () => {
|
||||
it('should reject string value when resourceLocator object is required', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: 'gpt-4o-mini' // Wrong - should be object with mode and value
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
displayName: 'Model',
|
||||
type: 'resourceLocator',
|
||||
required: true,
|
||||
default: { mode: 'list', value: 'gpt-4o-mini' }
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toMatchObject({
|
||||
type: 'invalid_type',
|
||||
property: 'model',
|
||||
message: expect.stringContaining('must be an object with \'mode\' and \'value\' properties')
|
||||
});
|
||||
expect(result.errors[0].fix).toContain('mode');
|
||||
expect(result.errors[0].fix).toContain('value');
|
||||
});
|
||||
|
||||
it('should accept valid resourceLocator with mode and value', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'list',
|
||||
value: 'gpt-4o-mini'
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
displayName: 'Model',
|
||||
type: 'resourceLocator',
|
||||
required: true,
|
||||
default: { mode: 'list', value: 'gpt-4o-mini' }
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject null value for resourceLocator', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: null
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e =>
|
||||
e.property === 'model' &&
|
||||
e.type === 'invalid_type'
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject array value for resourceLocator', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: ['gpt-4o-mini']
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e =>
|
||||
e.property === 'model' &&
|
||||
e.type === 'invalid_type' &&
|
||||
e.message.includes('must be an object')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect missing mode property in resourceLocator', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
value: 'gpt-4o-mini'
|
||||
// Missing mode property
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e =>
|
||||
e.property === 'model.mode' &&
|
||||
e.type === 'missing_required' &&
|
||||
e.message.includes('missing required property \'mode\'')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect missing value property in resourceLocator', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'list'
|
||||
// Missing value property
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
displayName: 'Model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e =>
|
||||
e.property === 'model.value' &&
|
||||
e.type === 'missing_required' &&
|
||||
e.message.includes('missing required property \'value\'')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect invalid mode type in resourceLocator', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 123, // Should be string
|
||||
value: 'gpt-4o-mini'
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e =>
|
||||
e.property === 'model.mode' &&
|
||||
e.type === 'invalid_type' &&
|
||||
e.message.includes('must be a string')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept resourceLocator with mode "id"', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'id',
|
||||
value: 'gpt-4o-2024-11-20'
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject number value when resourceLocator is required', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: 12345 // Wrong type
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0].type).toBe('invalid_type');
|
||||
expect(result.errors[0].message).toContain('must be an object');
|
||||
});
|
||||
|
||||
it('should provide helpful fix suggestion for string to resourceLocator conversion', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: 'gpt-4o-mini'
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.errors[0].fix).toContain('{ mode: "list", value: "gpt-4o-mini" }');
|
||||
expect(result.errors[0].fix).toContain('{ mode: "id", value: "gpt-4o-mini" }');
|
||||
});
|
||||
|
||||
it('should reject invalid mode values', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'invalid-mode',
|
||||
value: 'gpt-4o-mini'
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e =>
|
||||
e.property === 'model.mode' &&
|
||||
e.type === 'invalid_value' &&
|
||||
e.message.includes("must be 'list', 'id', or 'url'")
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept resourceLocator with mode "url"', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'url',
|
||||
value: 'https://api.example.com/models/custom'
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect empty resourceLocator object', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {} // Empty object, missing both mode and value
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThanOrEqual(2); // Both mode and value missing
|
||||
expect(result.errors.some(e => e.property === 'model.mode')).toBe(true);
|
||||
expect(result.errors.some(e => e.property === 'model.value')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle resourceLocator with extra properties gracefully', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'list',
|
||||
value: 'gpt-4o-mini',
|
||||
extraProperty: 'ignored' // Extra properties should be ignored
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(true); // Should pass with extra properties
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -79,6 +79,7 @@ describe('TemplateService', () => {
|
||||
getTemplateCount: vi.fn(),
|
||||
getTemplateStats: vi.fn(),
|
||||
getExistingTemplateIds: vi.fn(),
|
||||
getMostRecentTemplateDate: vi.fn(),
|
||||
clearTemplates: vi.fn(),
|
||||
saveTemplate: vi.fn(),
|
||||
rebuildTemplateFTS: vi.fn(),
|
||||
@@ -471,6 +472,7 @@ describe('TemplateService', () => {
|
||||
}));
|
||||
|
||||
mockRepository.getExistingTemplateIds = vi.fn().mockReturnValue(new Set([1, 2]));
|
||||
mockRepository.getMostRecentTemplateDate = vi.fn().mockReturnValue(new Date('2025-09-01'));
|
||||
mockRepository.saveTemplate = vi.fn();
|
||||
mockRepository.rebuildTemplateFTS = vi.fn();
|
||||
|
||||
@@ -498,6 +500,7 @@ describe('TemplateService', () => {
|
||||
}));
|
||||
|
||||
mockRepository.getExistingTemplateIds = vi.fn().mockReturnValue(new Set([1, 2]));
|
||||
mockRepository.getMostRecentTemplateDate = vi.fn().mockReturnValue(new Date('2025-09-01'));
|
||||
mockRepository.saveTemplate = vi.fn();
|
||||
mockRepository.rebuildTemplateFTS = vi.fn();
|
||||
|
||||
|
||||
@@ -582,13 +582,14 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
||||
expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-base.webhook');
|
||||
});
|
||||
|
||||
it('should skip node repository lookup for langchain nodes', async () => {
|
||||
it('should validate typeVersion but skip parameter validation for langchain nodes', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
typeVersion: 1,
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
}
|
||||
@@ -598,9 +599,39 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Langchain nodes should skip node repository validation
|
||||
// They are validated by dedicated AI validators instead
|
||||
expect(mockNodeRepository.getNode).not.toHaveBeenCalledWith('nodes-langchain.agent');
|
||||
// After v2.17.4 fix: Langchain nodes SHOULD call getNode for typeVersion validation
|
||||
// This prevents invalid typeVersion values from bypassing validation
|
||||
// But they skip parameter validation (handled by dedicated AI validators)
|
||||
expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-langchain.agent');
|
||||
|
||||
// Should not have typeVersion validation errors (other AI-specific errors may exist)
|
||||
const typeVersionErrors = result.errors.filter(e => e.message.includes('typeVersion'));
|
||||
expect(typeVersionErrors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should catch invalid typeVersion for langchain nodes (v2.17.4 bug fix)', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
typeVersion: 99999, // Invalid - exceeds maximum
|
||||
position: [100, 100],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
} as any;
|
||||
|
||||
const result = await validator.validateWorkflow(workflow as any);
|
||||
|
||||
// Critical: Before v2.17.4, this would pass validation but fail at runtime
|
||||
// After v2.17.4: Invalid typeVersion is caught during validation
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e =>
|
||||
e.message.includes('typeVersion 99999 exceeds maximum')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate typeVersion for versioned nodes', async () => {
|
||||
|
||||
@@ -504,4 +504,362 @@ describe('TelemetryConfigManager', () => {
|
||||
expect(typeof status).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Docker/Cloud user ID generation', () => {
|
||||
let originalIsDocker: string | undefined;
|
||||
let originalRailway: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalIsDocker = process.env.IS_DOCKER;
|
||||
originalRailway = process.env.RAILWAY_ENVIRONMENT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalIsDocker === undefined) {
|
||||
delete process.env.IS_DOCKER;
|
||||
} else {
|
||||
process.env.IS_DOCKER = originalIsDocker;
|
||||
}
|
||||
|
||||
if (originalRailway === undefined) {
|
||||
delete process.env.RAILWAY_ENVIRONMENT;
|
||||
} else {
|
||||
process.env.RAILWAY_ENVIRONMENT = originalRailway;
|
||||
}
|
||||
});
|
||||
|
||||
describe('boot_id reading', () => {
|
||||
it('should read valid boot_id from /proc/sys/kernel/random/boot_id', () => {
|
||||
const mockBootId = 'f3c371fe-8a77-4592-8332-7a4d0d88d4ac';
|
||||
process.env.IS_DOCKER = 'true';
|
||||
|
||||
vi.mocked(existsSync).mockImplementation((path: any) => {
|
||||
if (path === '/proc/sys/kernel/random/boot_id') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
vi.mocked(readFileSync).mockImplementation((path: any) => {
|
||||
if (path === '/proc/sys/kernel/random/boot_id') return mockBootId;
|
||||
throw new Error('File not found');
|
||||
});
|
||||
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
expect(vi.mocked(readFileSync)).toHaveBeenCalledWith(
|
||||
'/proc/sys/kernel/random/boot_id',
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate boot_id UUID format', () => {
|
||||
const invalidBootId = 'not-a-valid-uuid';
|
||||
process.env.IS_DOCKER = 'true';
|
||||
|
||||
vi.mocked(existsSync).mockImplementation((path: any) => {
|
||||
if (path === '/proc/sys/kernel/random/boot_id') return true;
|
||||
if (path === '/proc/cpuinfo') return true;
|
||||
if (path === '/proc/meminfo') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
vi.mocked(readFileSync).mockImplementation((path: any) => {
|
||||
if (path === '/proc/sys/kernel/random/boot_id') return invalidBootId;
|
||||
if (path === '/proc/cpuinfo') return 'processor: 0\nprocessor: 1\n';
|
||||
if (path === '/proc/meminfo') return 'MemTotal: 8040052 kB\n';
|
||||
throw new Error('File not found');
|
||||
});
|
||||
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
// Should fallback to combined fingerprint, not use invalid boot_id
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
|
||||
it('should handle boot_id file not existing', () => {
|
||||
process.env.IS_DOCKER = 'true';
|
||||
|
||||
vi.mocked(existsSync).mockImplementation((path: any) => {
|
||||
if (path === '/proc/sys/kernel/random/boot_id') return false;
|
||||
if (path === '/proc/cpuinfo') return true;
|
||||
if (path === '/proc/meminfo') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
vi.mocked(readFileSync).mockImplementation((path: any) => {
|
||||
if (path === '/proc/cpuinfo') return 'processor: 0\nprocessor: 1\n';
|
||||
if (path === '/proc/meminfo') return 'MemTotal: 8040052 kB\n';
|
||||
throw new Error('File not found');
|
||||
});
|
||||
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
// Should fallback to combined fingerprint
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
|
||||
it('should handle boot_id read errors gracefully', () => {
|
||||
process.env.IS_DOCKER = 'true';
|
||||
|
||||
vi.mocked(existsSync).mockImplementation((path: any) => {
|
||||
if (path === '/proc/sys/kernel/random/boot_id') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
vi.mocked(readFileSync).mockImplementation((path: any) => {
|
||||
if (path === '/proc/sys/kernel/random/boot_id') {
|
||||
throw new Error('Permission denied');
|
||||
}
|
||||
throw new Error('File not found');
|
||||
});
|
||||
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
// Should fallback gracefully
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
|
||||
it('should generate consistent user ID from same boot_id', () => {
|
||||
const mockBootId = 'f3c371fe-8a77-4592-8332-7a4d0d88d4ac';
|
||||
process.env.IS_DOCKER = 'true';
|
||||
|
||||
vi.mocked(existsSync).mockImplementation((path: any) => {
|
||||
if (path === '/proc/sys/kernel/random/boot_id') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
vi.mocked(readFileSync).mockImplementation((path: any) => {
|
||||
if (path === '/proc/sys/kernel/random/boot_id') return mockBootId;
|
||||
throw new Error('File not found');
|
||||
});
|
||||
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
const manager1 = TelemetryConfigManager.getInstance();
|
||||
const userId1 = manager1.getUserId();
|
||||
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
const manager2 = TelemetryConfigManager.getInstance();
|
||||
const userId2 = manager2.getUserId();
|
||||
|
||||
// Same boot_id should produce same user_id
|
||||
expect(userId1).toBe(userId2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('combined fingerprint fallback', () => {
|
||||
it('should generate fingerprint from CPU, memory, and kernel', () => {
|
||||
process.env.IS_DOCKER = 'true';
|
||||
|
||||
vi.mocked(existsSync).mockImplementation((path: any) => {
|
||||
if (path === '/proc/sys/kernel/random/boot_id') return false;
|
||||
if (path === '/proc/cpuinfo') return true;
|
||||
if (path === '/proc/meminfo') return true;
|
||||
if (path === '/proc/version') return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
vi.mocked(readFileSync).mockImplementation((path: any) => {
|
||||
if (path === '/proc/cpuinfo') return 'processor: 0\nprocessor: 1\nprocessor: 2\nprocessor: 3\n';
|
||||
if (path === '/proc/meminfo') return 'MemTotal: 8040052 kB\n';
|
||||
if (path === '/proc/version') return 'Linux version 5.15.49-linuxkit';
|
||||
throw new Error('File not found');
|
||||
});
|
||||
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
|
||||
it('should require at least 3 signals for combined fingerprint', () => {
|
||||
process.env.IS_DOCKER = 'true';
|
||||
|
||||
vi.mocked(existsSync).mockImplementation((path: any) => {
|
||||
if (path === '/proc/sys/kernel/random/boot_id') return false;
|
||||
// Only platform and arch available (2 signals)
|
||||
return false;
|
||||
});
|
||||
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
// Should fallback to generic Docker ID
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
|
||||
it('should handle partial /proc data', () => {
|
||||
process.env.IS_DOCKER = 'true';
|
||||
|
||||
vi.mocked(existsSync).mockImplementation((path: any) => {
|
||||
if (path === '/proc/sys/kernel/random/boot_id') return false;
|
||||
if (path === '/proc/cpuinfo') return true;
|
||||
// meminfo missing
|
||||
return false;
|
||||
});
|
||||
|
||||
vi.mocked(readFileSync).mockImplementation((path: any) => {
|
||||
if (path === '/proc/cpuinfo') return 'processor: 0\nprocessor: 1\n';
|
||||
throw new Error('File not found');
|
||||
});
|
||||
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
// Should include platform and arch, so 4 signals total
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('environment detection', () => {
|
||||
it('should use Docker method when IS_DOCKER=true', () => {
|
||||
process.env.IS_DOCKER = 'true';
|
||||
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
// Should attempt to read boot_id
|
||||
expect(vi.mocked(existsSync)).toHaveBeenCalledWith('/proc/sys/kernel/random/boot_id');
|
||||
});
|
||||
|
||||
it('should use Docker method for Railway environment', () => {
|
||||
process.env.RAILWAY_ENVIRONMENT = 'production';
|
||||
delete process.env.IS_DOCKER;
|
||||
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
// Should attempt to read boot_id
|
||||
expect(vi.mocked(existsSync)).toHaveBeenCalledWith('/proc/sys/kernel/random/boot_id');
|
||||
});
|
||||
|
||||
it('should use file-based method for local installation', () => {
|
||||
delete process.env.IS_DOCKER;
|
||||
delete process.env.RAILWAY_ENVIRONMENT;
|
||||
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
// Should NOT attempt to read boot_id
|
||||
const calls = vi.mocked(existsSync).mock.calls;
|
||||
const bootIdCalls = calls.filter(call => call[0] === '/proc/sys/kernel/random/boot_id');
|
||||
expect(bootIdCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should detect cloud platforms', () => {
|
||||
const cloudEnvVars = [
|
||||
'RAILWAY_ENVIRONMENT',
|
||||
'RENDER',
|
||||
'FLY_APP_NAME',
|
||||
'HEROKU_APP_NAME',
|
||||
'AWS_EXECUTION_ENV',
|
||||
'KUBERNETES_SERVICE_HOST',
|
||||
'GOOGLE_CLOUD_PROJECT',
|
||||
'AZURE_FUNCTIONS_ENVIRONMENT'
|
||||
];
|
||||
|
||||
cloudEnvVars.forEach(envVar => {
|
||||
// Clear all env vars
|
||||
cloudEnvVars.forEach(v => delete process.env[v]);
|
||||
delete process.env.IS_DOCKER;
|
||||
|
||||
// Set one cloud env var
|
||||
process.env[envVar] = 'true';
|
||||
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
|
||||
// Should attempt to read boot_id
|
||||
const calls = vi.mocked(existsSync).mock.calls;
|
||||
const bootIdCalls = calls.filter(call => call[0] === '/proc/sys/kernel/random/boot_id');
|
||||
expect(bootIdCalls.length).toBeGreaterThan(0);
|
||||
|
||||
// Clean up
|
||||
delete process.env[envVar];
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fallback chain execution', () => {
|
||||
it('should fallback from boot_id → combined → generic', () => {
|
||||
process.env.IS_DOCKER = 'true';
|
||||
|
||||
// All methods fail
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
vi.mocked(readFileSync).mockImplementation(() => {
|
||||
throw new Error('File not found');
|
||||
});
|
||||
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
manager = TelemetryConfigManager.getInstance();
|
||||
const userId = manager.getUserId();
|
||||
|
||||
// Should still generate a generic Docker ID
|
||||
expect(userId).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
|
||||
it('should use boot_id if available (highest priority)', () => {
|
||||
const mockBootId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
|
||||
process.env.IS_DOCKER = 'true';
|
||||
|
||||
vi.mocked(existsSync).mockImplementation((path: any) => {
|
||||
if (path === '/proc/sys/kernel/random/boot_id') return true;
|
||||
return true; // All other files available too
|
||||
});
|
||||
|
||||
vi.mocked(readFileSync).mockImplementation((path: any) => {
|
||||
if (path === '/proc/sys/kernel/random/boot_id') return mockBootId;
|
||||
if (path === '/proc/cpuinfo') return 'processor: 0\n';
|
||||
if (path === '/proc/meminfo') return 'MemTotal: 1000000 kB\n';
|
||||
return 'mock data';
|
||||
});
|
||||
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
const manager1 = TelemetryConfigManager.getInstance();
|
||||
const userId1 = manager1.getUserId();
|
||||
|
||||
// Now break boot_id but keep combined signals
|
||||
vi.mocked(existsSync).mockImplementation((path: any) => {
|
||||
if (path === '/proc/sys/kernel/random/boot_id') return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
(TelemetryConfigManager as any).instance = null;
|
||||
const manager2 = TelemetryConfigManager.getInstance();
|
||||
const userId2 = manager2.getUserId();
|
||||
|
||||
// Different methods should produce different IDs
|
||||
expect(userId1).not.toBe(userId2);
|
||||
expect(userId1).toMatch(/^[a-f0-9]{16}$/);
|
||||
expect(userId2).toMatch(/^[a-f0-9]{16}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user