Compare commits
65 Commits
feature/n8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e79b53465 | ||
|
|
8ce7c62299 | ||
|
|
15e6e97fd9 | ||
|
|
984af0a72f | ||
|
|
2df1f1b32b | ||
|
|
45fac6fe5e | ||
|
|
b65a2f8f3d | ||
|
|
f3658a4cab | ||
|
|
182016d932 | ||
|
|
36839a1c30 | ||
|
|
cac43ed384 | ||
|
|
8fd8c082ee | ||
|
|
baab3a02dc | ||
|
|
b2a5cf49f7 | ||
|
|
640e758c24 | ||
|
|
685171e9b7 | ||
|
|
567b54eaf7 | ||
|
|
bb774f8c70 | ||
|
|
fddc363221 | ||
|
|
13c1663489 | ||
|
|
48986263bf | ||
|
|
00f3f1fbfd | ||
|
|
a77379b40b | ||
|
|
680ccce47c | ||
|
|
c320eb4b35 | ||
|
|
f508d9873b | ||
|
|
9e322ad590 | ||
|
|
a4e711a4e8 | ||
|
|
bb39af3d9d | ||
|
|
999e31b13a | ||
|
|
72d90a2584 | ||
|
|
9003c24808 | ||
|
|
b944afa1bb | ||
|
|
ba3d1b35f2 | ||
|
|
6d95786938 | ||
|
|
21d4b9b9fb | ||
|
|
f3b777d8e8 | ||
|
|
035c4a349e | ||
|
|
08f3d8120d | ||
|
|
4b1aaa936d | ||
|
|
e94bb5479c | ||
|
|
1a99e9c6c7 | ||
|
|
7dc938065f | ||
|
|
8022ee1f65 | ||
|
|
9e71c71698 | ||
|
|
df4066022f | ||
|
|
7a71c3c3f8 | ||
|
|
3bfad51519 | ||
|
|
907d3846a9 | ||
|
|
6de82cd2b9 | ||
|
|
6856add177 | ||
|
|
3eecda4bd5 | ||
|
|
1c6bff7d42 | ||
|
|
8864d6fa5c | ||
|
|
f6906d7971 | ||
|
|
296bf76e68 | ||
|
|
a2be2b36d5 | ||
|
|
35b4e77bcd | ||
|
|
a5c60ddde1 | ||
|
|
066e7fc668 | ||
|
|
ff17fbcc0a | ||
|
|
f6c9548839 | ||
|
|
6b78c19545 | ||
|
|
7fbab3ec49 | ||
|
|
23327f5dc7 |
102
.claude/agents/n8n-mcp-tester.md
Normal file
102
.claude/agents/n8n-mcp-tester.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
---
|
||||||
|
name: n8n-mcp-tester
|
||||||
|
description: Use this agent when you need to test n8n-mcp functionality after implementing fixes or new features. This agent should be invoked proactively after code changes to validate that the MCP tools work correctly with real n8n workflows. The agent tests the actual MCP server tools, not just unit tests. Examples:\n\n<example>\nContext: Developer has just fixed a validation bug in the n8n-mcp server\nuser: "I've fixed the validation issue for HTTP Request nodes"\nassistant: "I've completed the fix. Now let me build the project and then test it."\n<builds project>\nassistant: "Project built successfully. Please reload the MCP server in Claude Desktop to apply the changes."\nuser: "Reloaded"\nassistant: "Great! Now let me invoke the n8n-mcp-tester agent to verify the fix works correctly."\n<commentary>\nSince a fix has been implemented and the MCP server reloaded, use the n8n-mcp-tester agent to validate the fix works in real-world usage.\n</commentary>\n</example>\n\n<example>\nContext: Developer has added a new MCP tool for workflow validation\nuser: "I've implemented the new workflow validation tool"\nassistant: "Implementation complete. Let me build and prepare for testing."\n<builds project>\nassistant: "Build successful. Please reload the MCP server to load the new tool."\nuser: "Done"\nassistant: "Perfect! I'll now use the n8n-mcp-tester agent to test the new workflow validation tool."\n<commentary>\nAfter implementing new MCP functionality and reloading the server, invoke n8n-mcp-tester to verify it works correctly.\n</commentary>\n</example>
|
||||||
|
tools: Glob, Grep, LS, Read, WebFetch, TodoWrite, WebSearch, mcp__puppeteer__puppeteer_navigate, mcp__puppeteer__puppeteer_screenshot, mcp__puppeteer__puppeteer_click, mcp__puppeteer__puppeteer_fill, mcp__puppeteer__puppeteer_select, mcp__puppeteer__puppeteer_hover, mcp__puppeteer__puppeteer_evaluate, ListMcpResourcesTool, ReadMcpResourceTool, mcp__supabase__list_organizations, mcp__supabase__get_organization, mcp__supabase__list_projects, mcp__supabase__get_project, mcp__supabase__get_cost, mcp__supabase__confirm_cost, mcp__supabase__create_project, mcp__supabase__pause_project, mcp__supabase__restore_project, mcp__supabase__create_branch, mcp__supabase__list_branches, mcp__supabase__delete_branch, mcp__supabase__merge_branch, mcp__supabase__reset_branch, mcp__supabase__rebase_branch, mcp__supabase__list_tables, mcp__supabase__list_extensions, mcp__supabase__list_migrations, mcp__supabase__apply_migration, mcp__supabase__execute_sql, mcp__supabase__get_logs, mcp__supabase__get_advisors, mcp__supabase__get_project_url, mcp__supabase__get_anon_key, mcp__supabase__generate_typescript_types, mcp__supabase__search_docs, mcp__supabase__list_edge_functions, mcp__supabase__deploy_edge_function, mcp__n8n-mcp__tools_documentation, mcp__n8n-mcp__list_nodes, mcp__n8n-mcp__get_node_info, mcp__n8n-mcp__search_nodes, mcp__n8n-mcp__list_ai_tools, mcp__n8n-mcp__get_node_documentation, mcp__n8n-mcp__get_database_statistics, mcp__n8n-mcp__get_node_essentials, mcp__n8n-mcp__search_node_properties, mcp__n8n-mcp__get_node_for_task, mcp__n8n-mcp__list_tasks, mcp__n8n-mcp__validate_node_operation, mcp__n8n-mcp__validate_node_minimal, mcp__n8n-mcp__get_property_dependencies, mcp__n8n-mcp__get_node_as_tool_info, mcp__n8n-mcp__list_node_templates, mcp__n8n-mcp__get_template, mcp__n8n-mcp__search_templates, mcp__n8n-mcp__get_templates_for_task, mcp__n8n-mcp__validate_workflow, mcp__n8n-mcp__validate_workflow_connections, mcp__n8n-mcp__validate_workflow_expressions, mcp__n8n-mcp__n8n_create_workflow, mcp__n8n-mcp__n8n_get_workflow, mcp__n8n-mcp__n8n_get_workflow_details, mcp__n8n-mcp__n8n_get_workflow_structure, mcp__n8n-mcp__n8n_get_workflow_minimal, mcp__n8n-mcp__n8n_update_full_workflow, mcp__n8n-mcp__n8n_update_partial_workflow, mcp__n8n-mcp__n8n_delete_workflow, mcp__n8n-mcp__n8n_list_workflows, mcp__n8n-mcp__n8n_validate_workflow, mcp__n8n-mcp__n8n_trigger_webhook_workflow, mcp__n8n-mcp__n8n_get_execution, mcp__n8n-mcp__n8n_list_executions, mcp__n8n-mcp__n8n_delete_execution, mcp__n8n-mcp__n8n_health_check, mcp__n8n-mcp__n8n_list_available_tools, mcp__n8n-mcp__n8n_diagnostic
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
You are n8n-mcp-tester, a specialized testing agent for the n8n Model Context Protocol (MCP) server. You validate that MCP tools and functionality work correctly in real-world scenarios after fixes or new features are implemented.
|
||||||
|
|
||||||
|
## Your Core Responsibilities
|
||||||
|
|
||||||
|
You test the n8n-mcp server by:
|
||||||
|
1. Using MCP tools to build, validate, and manipulate n8n workflows
|
||||||
|
2. Verifying that recent fixes resolve the reported issues
|
||||||
|
3. Testing new functionality works as designed
|
||||||
|
4. Reporting clear, actionable results back to the invoking agent
|
||||||
|
|
||||||
|
## Testing Methodology
|
||||||
|
|
||||||
|
When invoked with a test request, you will:
|
||||||
|
|
||||||
|
1. **Understand the Context**: Identify what was fixed or added based on the instructions from the invoking agent
|
||||||
|
|
||||||
|
2. **Design Test Scenarios**: Create specific test cases that:
|
||||||
|
- Target the exact functionality that was changed
|
||||||
|
- Include both positive and negative test cases
|
||||||
|
- Test edge cases and boundary conditions
|
||||||
|
- Use realistic n8n workflow configurations
|
||||||
|
|
||||||
|
3. **Execute Tests Using MCP Tools**: You have access to all n8n-mcp tools including:
|
||||||
|
- `search_nodes`: Find relevant n8n nodes
|
||||||
|
- `get_node_info`: Get detailed node configuration
|
||||||
|
- `get_node_essentials`: Get simplified node information
|
||||||
|
- `validate_node_config`: Validate node configurations
|
||||||
|
- `n8n_validate_workflow`: Validate complete workflows
|
||||||
|
- `get_node_example`: Get working examples
|
||||||
|
- `search_templates`: Find workflow templates
|
||||||
|
- Additional tools as available in the MCP server
|
||||||
|
|
||||||
|
4. **Verify Expected Behavior**:
|
||||||
|
- Confirm fixes resolve the original issue
|
||||||
|
- Verify new features work as documented
|
||||||
|
- Check for regressions in related functionality
|
||||||
|
- Test error handling and edge cases
|
||||||
|
|
||||||
|
5. **Report Results**: Provide clear feedback including:
|
||||||
|
- What was tested (specific tools and scenarios)
|
||||||
|
- Whether the fix/feature works as expected
|
||||||
|
- Any unexpected behaviors or issues discovered
|
||||||
|
- Specific error messages if failures occur
|
||||||
|
- Recommendations for additional testing if needed
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
- **Be Thorough**: Test multiple variations and edge cases
|
||||||
|
- **Be Specific**: Use exact node types, properties, and configurations mentioned in the fix
|
||||||
|
- **Be Realistic**: Create test scenarios that mirror actual n8n usage
|
||||||
|
- **Be Clear**: Report results in a structured, easy-to-understand format
|
||||||
|
- **Be Efficient**: Focus testing on the changed functionality first
|
||||||
|
|
||||||
|
## Example Test Execution
|
||||||
|
|
||||||
|
If testing a validation fix for HTTP Request nodes:
|
||||||
|
1. Call `tools_documentation` to get a list of available tools and get documentation on `search_nodes` tool.
|
||||||
|
2. Search for HTTP Request node using `search_nodes`
|
||||||
|
3. Get node configuration with `get_node_info` or `get_node_essentials`
|
||||||
|
4. Create test configurations that previously failed
|
||||||
|
5. Validate using `validate_node_config` with different profiles
|
||||||
|
6. Test in a complete workflow using `n8n_validate_workflow`
|
||||||
|
6. Report whether validation now works correctly
|
||||||
|
|
||||||
|
## Important Constraints
|
||||||
|
|
||||||
|
- You can only test using the MCP tools available in the server
|
||||||
|
- You cannot modify code or files - only test existing functionality
|
||||||
|
- You must work with the current state of the MCP server (already reloaded)
|
||||||
|
- Focus on functional testing, not unit testing
|
||||||
|
- Report issues objectively without attempting to fix them
|
||||||
|
|
||||||
|
## Response Format
|
||||||
|
|
||||||
|
Structure your test results as:
|
||||||
|
|
||||||
|
```
|
||||||
|
### Test Report: [Feature/Fix Name]
|
||||||
|
|
||||||
|
**Test Objective**: [What was being tested]
|
||||||
|
|
||||||
|
**Test Scenarios**:
|
||||||
|
1. [Scenario 1]: ✅/❌ [Result]
|
||||||
|
2. [Scenario 2]: ✅/❌ [Result]
|
||||||
|
|
||||||
|
**Findings**:
|
||||||
|
- [Key finding 1]
|
||||||
|
- [Key finding 2]
|
||||||
|
|
||||||
|
**Conclusion**: [Overall assessment - works as expected / issues found]
|
||||||
|
|
||||||
|
**Details**: [Any error messages, unexpected behaviors, or additional context]
|
||||||
|
```
|
||||||
|
|
||||||
|
Remember: Your role is to validate that the n8n-mcp server works correctly in practice, providing confidence that fixes and new features function as intended before deployment.
|
||||||
31
.github/workflows/benchmark-pr.yml
vendored
31
.github/workflows/benchmark-pr.yml
vendored
@@ -2,11 +2,19 @@ name: Benchmark PR Comparison
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths-ignore:
|
||||||
- 'src/**'
|
- '**.md'
|
||||||
- 'tests/benchmarks/**'
|
- '**.txt'
|
||||||
- 'package.json'
|
- 'docs/**'
|
||||||
- 'vitest.config.benchmark.ts'
|
- 'examples/**'
|
||||||
|
- '.github/FUNDING.yml'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- '.github/pull_request_template.md'
|
||||||
|
- '.gitignore'
|
||||||
|
- 'LICENSE*'
|
||||||
|
- 'ATTRIBUTION.md'
|
||||||
|
- 'SECURITY.md'
|
||||||
|
- 'CODE_OF_CONDUCT.md'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
@@ -85,8 +93,10 @@ jobs:
|
|||||||
- name: Post benchmark comparison to PR
|
- name: Post benchmark comparison to PR
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
|
try {
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
let comment = '## ⚡ Benchmark Comparison\n\n';
|
let comment = '## ⚡ Benchmark Comparison\n\n';
|
||||||
|
|
||||||
@@ -131,13 +141,20 @@ jobs:
|
|||||||
body: comment
|
body: comment
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create/update PR comment:', error.message);
|
||||||
|
console.log('This is likely due to insufficient permissions for external PRs.');
|
||||||
|
console.log('Benchmark comparison has been saved to artifacts instead.');
|
||||||
|
}
|
||||||
|
|
||||||
# Add status check
|
# Add status check
|
||||||
- name: Set benchmark status
|
- name: Set benchmark status
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
|
try {
|
||||||
const hasRegression = '${{ steps.compare.outputs.REGRESSION }}' === 'true';
|
const hasRegression = '${{ steps.compare.outputs.REGRESSION }}' === 'true';
|
||||||
const state = hasRegression ? 'failure' : 'success';
|
const state = hasRegression ? 'failure' : 'success';
|
||||||
const description = hasRegression
|
const description = hasRegression
|
||||||
@@ -153,3 +170,7 @@ jobs:
|
|||||||
description: description,
|
description: description,
|
||||||
context: 'benchmarks/regression-check'
|
context: 'benchmarks/regression-check'
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create commit status:', error.message);
|
||||||
|
console.log('This is likely due to insufficient permissions for external PRs.');
|
||||||
|
}
|
||||||
40
.github/workflows/benchmark.yml
vendored
40
.github/workflows/benchmark.yml
vendored
@@ -3,8 +3,34 @@ name: Performance Benchmarks
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, feat/comprehensive-testing-suite]
|
branches: [main, feat/comprehensive-testing-suite]
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- '**.txt'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'examples/**'
|
||||||
|
- '.github/FUNDING.yml'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- '.github/pull_request_template.md'
|
||||||
|
- '.gitignore'
|
||||||
|
- 'LICENSE*'
|
||||||
|
- 'ATTRIBUTION.md'
|
||||||
|
- 'SECURITY.md'
|
||||||
|
- 'CODE_OF_CONDUCT.md'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- '**.txt'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'examples/**'
|
||||||
|
- '.github/FUNDING.yml'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- '.github/pull_request_template.md'
|
||||||
|
- '.gitignore'
|
||||||
|
- 'LICENSE*'
|
||||||
|
- 'ATTRIBUTION.md'
|
||||||
|
- 'SECURITY.md'
|
||||||
|
- 'CODE_OF_CONDUCT.md'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -77,12 +103,14 @@ jobs:
|
|||||||
# Store benchmark results and compare
|
# Store benchmark results and compare
|
||||||
- name: Store benchmark result
|
- name: Store benchmark result
|
||||||
uses: benchmark-action/github-action-benchmark@v1
|
uses: benchmark-action/github-action-benchmark@v1
|
||||||
|
continue-on-error: true
|
||||||
|
id: benchmark
|
||||||
with:
|
with:
|
||||||
name: n8n-mcp Benchmarks
|
name: n8n-mcp Benchmarks
|
||||||
tool: 'customSmallerIsBetter'
|
tool: 'customSmallerIsBetter'
|
||||||
output-file-path: benchmark-results-formatted.json
|
output-file-path: benchmark-results-formatted.json
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
auto-push: true
|
auto-push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||||
# Where to store benchmark data
|
# Where to store benchmark data
|
||||||
benchmark-data-dir-path: 'benchmarks'
|
benchmark-data-dir-path: 'benchmarks'
|
||||||
# Alert when performance regresses by 10%
|
# Alert when performance regresses by 10%
|
||||||
@@ -94,14 +122,17 @@ jobs:
|
|||||||
summary-always: true
|
summary-always: true
|
||||||
# Max number of data points to retain
|
# Max number of data points to retain
|
||||||
max-items-in-chart: 50
|
max-items-in-chart: 50
|
||||||
|
fail-on-alert: false
|
||||||
|
|
||||||
# Comment on PR with benchmark results
|
# Comment on PR with benchmark results
|
||||||
- name: Comment PR with results
|
- name: Comment PR with results
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
|
try {
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const summary = JSON.parse(fs.readFileSync('benchmark-summary.json', 'utf8'));
|
const summary = JSON.parse(fs.readFileSync('benchmark-summary.json', 'utf8'));
|
||||||
|
|
||||||
@@ -134,12 +165,17 @@ jobs:
|
|||||||
comment += '\n\n📈 [View historical benchmark trends](https://czlonkowski.github.io/n8n-mcp/benchmarks/)\n';
|
comment += '\n\n📈 [View historical benchmark trends](https://czlonkowski.github.io/n8n-mcp/benchmarks/)\n';
|
||||||
comment += '\n⚡ Performance regressions >10% will be flagged automatically.\n';
|
comment += '\n⚡ Performance regressions >10% will be flagged automatically.\n';
|
||||||
|
|
||||||
github.rest.issues.createComment({
|
await github.rest.issues.createComment({
|
||||||
issue_number: context.issue.number,
|
issue_number: context.issue.number,
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
body: comment
|
body: comment
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create PR comment:', error.message);
|
||||||
|
console.log('This is likely due to insufficient permissions for external PRs.');
|
||||||
|
console.log('Benchmark results have been saved to artifacts instead.');
|
||||||
|
}
|
||||||
|
|
||||||
# Deploy benchmark results to GitHub Pages
|
# Deploy benchmark results to GitHub Pages
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
42
.github/workflows/docker-build-n8n.yml
vendored
42
.github/workflows/docker-build-n8n.yml
vendored
@@ -6,9 +6,35 @@ on:
|
|||||||
- main
|
- main
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- '**.txt'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'examples/**'
|
||||||
|
- '.github/FUNDING.yml'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- '.github/pull_request_template.md'
|
||||||
|
- '.gitignore'
|
||||||
|
- 'LICENSE*'
|
||||||
|
- 'ATTRIBUTION.md'
|
||||||
|
- 'SECURITY.md'
|
||||||
|
- 'CODE_OF_CONDUCT.md'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- '**.txt'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'examples/**'
|
||||||
|
- '.github/FUNDING.yml'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- '.github/pull_request_template.md'
|
||||||
|
- '.gitignore'
|
||||||
|
- 'LICENSE*'
|
||||||
|
- 'ATTRIBUTION.md'
|
||||||
|
- 'SECURITY.md'
|
||||||
|
- 'CODE_OF_CONDUCT.md'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -53,7 +79,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.n8n
|
file: ./Dockerfile
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -82,13 +108,16 @@ jobs:
|
|||||||
|
|
||||||
- name: Test Docker image
|
- name: Test Docker image
|
||||||
run: |
|
run: |
|
||||||
|
# Test that the image starts correctly with N8N_MODE
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-e N8N_MODE=true \
|
-e N8N_MODE=true \
|
||||||
|
-e MCP_MODE=http \
|
||||||
-e N8N_API_URL=http://localhost:5678 \
|
-e N8N_API_URL=http://localhost:5678 \
|
||||||
-e N8N_API_KEY=test \
|
-e N8N_API_KEY=test \
|
||||||
-e MCP_AUTH_TOKEN=test \
|
-e MCP_AUTH_TOKEN=test-token-minimum-32-chars-long \
|
||||||
|
-e AUTH_TOKEN=test-token-minimum-32-chars-long \
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
|
||||||
node dist/index.js n8n --version
|
node -e "console.log('N8N_MODE:', process.env.N8N_MODE); process.exit(0);"
|
||||||
|
|
||||||
- name: Test health endpoint
|
- name: Test health endpoint
|
||||||
run: |
|
run: |
|
||||||
@@ -97,9 +126,11 @@ jobs:
|
|||||||
--name n8n-mcp-test \
|
--name n8n-mcp-test \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-e N8N_MODE=true \
|
-e N8N_MODE=true \
|
||||||
|
-e MCP_MODE=http \
|
||||||
-e N8N_API_URL=http://localhost:5678 \
|
-e N8N_API_URL=http://localhost:5678 \
|
||||||
-e N8N_API_KEY=test \
|
-e N8N_API_KEY=test \
|
||||||
-e MCP_AUTH_TOKEN=test \
|
-e MCP_AUTH_TOKEN=test-token-minimum-32-chars-long \
|
||||||
|
-e AUTH_TOKEN=test-token-minimum-32-chars-long \
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
|
||||||
# Wait for container to start
|
# Wait for container to start
|
||||||
@@ -108,6 +139,9 @@ jobs:
|
|||||||
# Test health endpoint
|
# Test health endpoint
|
||||||
curl -f http://localhost:3000/health || exit 1
|
curl -f http://localhost:3000/health || exit 1
|
||||||
|
|
||||||
|
# Test MCP endpoint
|
||||||
|
curl -f http://localhost:3000/mcp || exit 1
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
docker stop n8n-mcp-test
|
docker stop n8n-mcp-test
|
||||||
docker rm n8n-mcp-test
|
docker rm n8n-mcp-test
|
||||||
|
|||||||
18
.github/workflows/docker-build.yml
vendored
18
.github/workflows/docker-build.yml
vendored
@@ -9,23 +9,33 @@ on:
|
|||||||
- 'v*'
|
- 'v*'
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- '**.md'
|
- '**.md'
|
||||||
|
- '**.txt'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'examples/**'
|
||||||
- '.github/FUNDING.yml'
|
- '.github/FUNDING.yml'
|
||||||
- '.github/ISSUE_TEMPLATE/**'
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
- '.github/pull_request_template.md'
|
- '.github/pull_request_template.md'
|
||||||
- 'LICENSE'
|
- '.gitignore'
|
||||||
|
- 'LICENSE*'
|
||||||
- 'ATTRIBUTION.md'
|
- 'ATTRIBUTION.md'
|
||||||
- 'docs/**'
|
- 'SECURITY.md'
|
||||||
|
- 'CODE_OF_CONDUCT.md'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- '**.md'
|
- '**.md'
|
||||||
|
- '**.txt'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'examples/**'
|
||||||
- '.github/FUNDING.yml'
|
- '.github/FUNDING.yml'
|
||||||
- '.github/ISSUE_TEMPLATE/**'
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
- '.github/pull_request_template.md'
|
- '.github/pull_request_template.md'
|
||||||
- 'LICENSE'
|
- '.gitignore'
|
||||||
|
- 'LICENSE*'
|
||||||
- 'ATTRIBUTION.md'
|
- 'ATTRIBUTION.md'
|
||||||
- 'docs/**'
|
- 'SECURITY.md'
|
||||||
|
- 'CODE_OF_CONDUCT.md'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
|||||||
513
.github/workflows/release.yml
vendored
Normal file
513
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
name: Automated Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'package.json'
|
||||||
|
- 'package.runtime.json'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
# Prevent concurrent releases
|
||||||
|
concurrency:
|
||||||
|
group: release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
detect-version-change:
|
||||||
|
name: Detect Version Change
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version-changed: ${{ steps.check.outputs.changed }}
|
||||||
|
new-version: ${{ steps.check.outputs.version }}
|
||||||
|
previous-version: ${{ steps.check.outputs.previous-version }}
|
||||||
|
is-prerelease: ${{ steps.check.outputs.is-prerelease }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Check for version change
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
# Get current version from package.json
|
||||||
|
CURRENT_VERSION=$(node -e "console.log(require('./package.json').version)")
|
||||||
|
|
||||||
|
# Get previous version from git history safely
|
||||||
|
PREVIOUS_VERSION=$(git show HEAD~1:package.json 2>/dev/null | node -e "
|
||||||
|
try {
|
||||||
|
const data = require('fs').readFileSync(0, 'utf8');
|
||||||
|
const pkg = JSON.parse(data);
|
||||||
|
console.log(pkg.version || '0.0.0');
|
||||||
|
} catch (e) {
|
||||||
|
console.log('0.0.0');
|
||||||
|
}
|
||||||
|
" || echo "0.0.0")
|
||||||
|
|
||||||
|
echo "Previous version: $PREVIOUS_VERSION"
|
||||||
|
echo "Current version: $CURRENT_VERSION"
|
||||||
|
|
||||||
|
# Check if version changed
|
||||||
|
if [ "$CURRENT_VERSION" != "$PREVIOUS_VERSION" ]; then
|
||||||
|
echo "changed=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "previous-version=$PREVIOUS_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Check if it's a prerelease (contains alpha, beta, rc, dev)
|
||||||
|
if echo "$CURRENT_VERSION" | grep -E "(alpha|beta|rc|dev)" > /dev/null; then
|
||||||
|
echo "is-prerelease=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "is-prerelease=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🎉 Version changed from $PREVIOUS_VERSION to $CURRENT_VERSION"
|
||||||
|
else
|
||||||
|
echo "changed=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "previous-version=$PREVIOUS_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "is-prerelease=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "ℹ️ No version change detected"
|
||||||
|
fi
|
||||||
|
|
||||||
|
extract-changelog:
|
||||||
|
name: Extract Changelog
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: detect-version-change
|
||||||
|
if: needs.detect-version-change.outputs.version-changed == 'true'
|
||||||
|
outputs:
|
||||||
|
release-notes: ${{ steps.extract.outputs.notes }}
|
||||||
|
has-notes: ${{ steps.extract.outputs.has-notes }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Extract changelog for version
|
||||||
|
id: extract
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.detect-version-change.outputs.new-version }}"
|
||||||
|
CHANGELOG_FILE="docs/CHANGELOG.md"
|
||||||
|
|
||||||
|
if [ ! -f "$CHANGELOG_FILE" ]; then
|
||||||
|
echo "Changelog file not found at $CHANGELOG_FILE"
|
||||||
|
echo "has-notes=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "notes=No changelog entries found for version $VERSION" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use the extracted changelog script
|
||||||
|
if NOTES=$(node scripts/extract-changelog.js "$VERSION" "$CHANGELOG_FILE" 2>/dev/null); then
|
||||||
|
echo "has-notes=true" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Use heredoc to properly handle multiline content
|
||||||
|
{
|
||||||
|
echo "notes<<EOF"
|
||||||
|
echo "$NOTES"
|
||||||
|
echo "EOF"
|
||||||
|
} >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
echo "✅ Successfully extracted changelog for version $VERSION"
|
||||||
|
else
|
||||||
|
echo "has-notes=false" >> $GITHUB_OUTPUT
|
||||||
|
echo "notes=No changelog entries found for version $VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "⚠️ Could not extract changelog for version $VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
create-release:
|
||||||
|
name: Create GitHub Release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [detect-version-change, extract-changelog]
|
||||||
|
if: needs.detect-version-change.outputs.version-changed == 'true'
|
||||||
|
outputs:
|
||||||
|
release-id: ${{ steps.create.outputs.id }}
|
||||||
|
upload-url: ${{ steps.create.outputs.upload_url }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Create Git Tag
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.detect-version-change.outputs.new-version }}"
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
# Create annotated tag
|
||||||
|
git tag -a "v$VERSION" -m "Release v$VERSION"
|
||||||
|
git push origin "v$VERSION"
|
||||||
|
|
||||||
|
- name: Create GitHub Release
|
||||||
|
id: create
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.detect-version-change.outputs.new-version }}"
|
||||||
|
IS_PRERELEASE="${{ needs.detect-version-change.outputs.is-prerelease }}"
|
||||||
|
|
||||||
|
# Create release body
|
||||||
|
cat > release_body.md << 'EOF'
|
||||||
|
# Release v${{ needs.detect-version-change.outputs.new-version }}
|
||||||
|
|
||||||
|
${{ needs.extract-changelog.outputs.release-notes }}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### NPM Package
|
||||||
|
```bash
|
||||||
|
# Install globally
|
||||||
|
npm install -g n8n-mcp
|
||||||
|
|
||||||
|
# Or run directly
|
||||||
|
npx n8n-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
```bash
|
||||||
|
# Standard image
|
||||||
|
docker run -p 3000:3000 ghcr.io/czlonkowski/n8n-mcp:v${{ needs.detect-version-change.outputs.new-version }}
|
||||||
|
|
||||||
|
# Railway optimized
|
||||||
|
docker run -p 3000:3000 ghcr.io/czlonkowski/n8n-mcp-railway:v${{ needs.detect-version-change.outputs.new-version }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
- [Installation Guide](https://github.com/czlonkowski/n8n-mcp#installation)
|
||||||
|
- [Docker Deployment](https://github.com/czlonkowski/n8n-mcp/blob/main/docs/DOCKER_README.md)
|
||||||
|
- [n8n Integration](https://github.com/czlonkowski/n8n-mcp/blob/main/docs/N8N_DEPLOYMENT.md)
|
||||||
|
- [Complete Changelog](https://github.com/czlonkowski/n8n-mcp/blob/main/docs/CHANGELOG.md)
|
||||||
|
|
||||||
|
🤖 *Generated with [Claude Code](https://claude.ai/code)*
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Create release using gh CLI
|
||||||
|
if [ "$IS_PRERELEASE" = "true" ]; then
|
||||||
|
PRERELEASE_FLAG="--prerelease"
|
||||||
|
else
|
||||||
|
PRERELEASE_FLAG=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
gh release create "v$VERSION" \
|
||||||
|
--title "Release v$VERSION" \
|
||||||
|
--notes-file release_body.md \
|
||||||
|
$PRERELEASE_FLAG
|
||||||
|
|
||||||
|
# Output release info for next jobs
|
||||||
|
RELEASE_ID=$(gh release view "v$VERSION" --json id --jq '.id')
|
||||||
|
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
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: detect-version-change
|
||||||
|
if: needs.detect-version-change.outputs.version-changed == 'true'
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: 'npm'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build project
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Rebuild database
|
||||||
|
run: npm run rebuild
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
|
||||||
|
- 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]
|
||||||
|
if: needs.detect-version-change.outputs.version-changed == 'true'
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: 'npm'
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build project
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Rebuild database
|
||||||
|
run: npm run rebuild
|
||||||
|
|
||||||
|
- name: Sync runtime version
|
||||||
|
run: npm run sync:runtime-version
|
||||||
|
|
||||||
|
- name: Prepare package for publishing
|
||||||
|
run: |
|
||||||
|
# Create publish directory
|
||||||
|
PUBLISH_DIR="npm-publish-temp"
|
||||||
|
rm -rf $PUBLISH_DIR
|
||||||
|
mkdir -p $PUBLISH_DIR
|
||||||
|
|
||||||
|
# Copy necessary files
|
||||||
|
cp -r dist $PUBLISH_DIR/
|
||||||
|
cp -r data $PUBLISH_DIR/
|
||||||
|
cp README.md $PUBLISH_DIR/
|
||||||
|
cp LICENSE $PUBLISH_DIR/
|
||||||
|
cp .env.example $PUBLISH_DIR/
|
||||||
|
|
||||||
|
# Use runtime package.json as base
|
||||||
|
cp package.runtime.json $PUBLISH_DIR/package.json
|
||||||
|
|
||||||
|
cd $PUBLISH_DIR
|
||||||
|
|
||||||
|
# Update package.json with complete metadata
|
||||||
|
node -e "
|
||||||
|
const pkg = require('./package.json');
|
||||||
|
pkg.name = 'n8n-mcp';
|
||||||
|
pkg.description = 'Integration between n8n workflow automation and Model Context Protocol (MCP)';
|
||||||
|
pkg.bin = { 'n8n-mcp': './dist/mcp/index.js' };
|
||||||
|
pkg.repository = { type: 'git', url: 'git+https://github.com/czlonkowski/n8n-mcp.git' };
|
||||||
|
pkg.keywords = ['n8n', 'mcp', 'model-context-protocol', 'ai', 'workflow', 'automation'];
|
||||||
|
pkg.author = 'Romuald Czlonkowski @ www.aiadvisors.pl/en';
|
||||||
|
pkg.license = 'MIT';
|
||||||
|
pkg.bugs = { url: 'https://github.com/czlonkowski/n8n-mcp/issues' };
|
||||||
|
pkg.homepage = 'https://github.com/czlonkowski/n8n-mcp#readme';
|
||||||
|
pkg.files = ['dist/**/*', 'data/nodes.db', '.env.example', 'README.md', 'LICENSE'];
|
||||||
|
delete pkg.private;
|
||||||
|
require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2));
|
||||||
|
"
|
||||||
|
|
||||||
|
echo "Package prepared for publishing:"
|
||||||
|
echo "Name: $(node -e "console.log(require('./package.json').name)")"
|
||||||
|
echo "Version: $(node -e "console.log(require('./package.json').version)")"
|
||||||
|
|
||||||
|
- name: Publish to NPM with retry
|
||||||
|
uses: nick-invision/retry@v2
|
||||||
|
with:
|
||||||
|
timeout_minutes: 5
|
||||||
|
max_attempts: 3
|
||||||
|
command: |
|
||||||
|
cd npm-publish-temp
|
||||||
|
npm publish --access public
|
||||||
|
env:
|
||||||
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
|
- name: Clean up
|
||||||
|
if: always()
|
||||||
|
run: rm -rf npm-publish-temp
|
||||||
|
|
||||||
|
build-docker:
|
||||||
|
name: Build and Push Docker Images
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [detect-version-change, build-and-test]
|
||||||
|
if: needs.detect-version-change.outputs.version-changed == 'true'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
lfs: true
|
||||||
|
|
||||||
|
- name: Check disk space
|
||||||
|
run: |
|
||||||
|
echo "Disk usage before Docker build:"
|
||||||
|
df -h
|
||||||
|
|
||||||
|
# Check available space (require at least 2GB)
|
||||||
|
AVAILABLE_GB=$(df / --output=avail --block-size=1G | tail -1)
|
||||||
|
if [ "$AVAILABLE_GB" -lt 2 ]; then
|
||||||
|
echo "❌ Insufficient disk space: ${AVAILABLE_GB}GB available, 2GB required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Sufficient disk space: ${AVAILABLE_GB}GB available"
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata for standard image
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}},value=v${{ needs.detect-version-change.outputs.new-version }}
|
||||||
|
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.detect-version-change.outputs.new-version }}
|
||||||
|
type=semver,pattern={{major}},value=v${{ needs.detect-version-change.outputs.new-version }}
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build and push standard Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Extract metadata for Railway image
|
||||||
|
id: meta-railway
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-railway
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}},value=v${{ needs.detect-version-change.outputs.new-version }}
|
||||||
|
type=semver,pattern={{major}}.{{minor}},value=v${{ needs.detect-version-change.outputs.new-version }}
|
||||||
|
type=semver,pattern={{major}},value=v${{ needs.detect-version-change.outputs.new-version }}
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build and push Railway Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile.railway
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta-railway.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta-railway.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
update-documentation:
|
||||||
|
name: Update Documentation
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [detect-version-change, create-release, publish-npm, build-docker]
|
||||||
|
if: needs.detect-version-change.outputs.version-changed == 'true' && !failure()
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Update version badges in README
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.detect-version-change.outputs.new-version }}"
|
||||||
|
|
||||||
|
# Update README version badges
|
||||||
|
if [ -f "README.md" ]; then
|
||||||
|
# Update npm version badge
|
||||||
|
sed -i.bak "s|npm/v/n8n-mcp/[^)]*|npm/v/n8n-mcp/$VERSION|g" README.md
|
||||||
|
|
||||||
|
# Update any other version references
|
||||||
|
sed -i.bak "s|version-[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*|version-$VERSION|g" README.md
|
||||||
|
|
||||||
|
# Clean up backup file
|
||||||
|
rm -f README.md.bak
|
||||||
|
|
||||||
|
echo "✅ Updated version badges in README.md to $VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Commit documentation updates
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.detect-version-change.outputs.new-version }}
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
if git diff --quiet; then
|
||||||
|
echo "No documentation changes to commit"
|
||||||
|
else
|
||||||
|
git add README.md
|
||||||
|
git commit -m "docs: update version badges to v${VERSION}"
|
||||||
|
git push
|
||||||
|
echo "✅ Committed documentation updates"
|
||||||
|
fi
|
||||||
|
|
||||||
|
notify-completion:
|
||||||
|
name: Notify Release Completion
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [detect-version-change, create-release, publish-npm, build-docker, update-documentation]
|
||||||
|
if: always() && needs.detect-version-change.outputs.version-changed == 'true'
|
||||||
|
steps:
|
||||||
|
- name: Create release summary
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.detect-version-change.outputs.new-version }}"
|
||||||
|
RELEASE_URL="https://github.com/${{ github.repository }}/releases/tag/v$VERSION"
|
||||||
|
|
||||||
|
echo "## 🎉 Release v$VERSION Published Successfully!" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "### ✅ Completed Tasks:" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
# Check job statuses
|
||||||
|
if [ "${{ needs.create-release.result }}" = "success" ]; then
|
||||||
|
echo "- ✅ GitHub Release created: [$RELEASE_URL]($RELEASE_URL)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "- ❌ GitHub Release creation failed" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${{ needs.publish-npm.result }}" = "success" ]; then
|
||||||
|
echo "- ✅ NPM package published: [npmjs.com/package/n8n-mcp](https://www.npmjs.com/package/n8n-mcp)" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "- ❌ NPM publishing failed" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${{ needs.build-docker.result }}" = "success" ]; then
|
||||||
|
echo "- ✅ Docker images built and pushed" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo " - Standard: \`ghcr.io/czlonkowski/n8n-mcp:v$VERSION\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo " - Railway: \`ghcr.io/czlonkowski/n8n-mcp-railway:v$VERSION\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "- ❌ Docker image building failed" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${{ needs.update-documentation.result }}" = "success" ]; then
|
||||||
|
echo "- ✅ Documentation updated" >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "- ⚠️ Documentation update skipped or failed" >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "### 📦 Installation:" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "# NPM" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "npx n8n-mcp" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "# Docker" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "docker run -p 3000:3000 ghcr.io/czlonkowski/n8n-mcp:v$VERSION" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
echo "🎉 Release automation completed for v$VERSION!"
|
||||||
35
.github/workflows/test.yml
vendored
35
.github/workflows/test.yml
vendored
@@ -2,8 +2,34 @@ name: Test Suite
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, feat/comprehensive-testing-suite]
|
branches: [main, feat/comprehensive-testing-suite]
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- '**.txt'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'examples/**'
|
||||||
|
- '.github/FUNDING.yml'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- '.github/pull_request_template.md'
|
||||||
|
- '.gitignore'
|
||||||
|
- 'LICENSE*'
|
||||||
|
- 'ATTRIBUTION.md'
|
||||||
|
- 'SECURITY.md'
|
||||||
|
- 'CODE_OF_CONDUCT.md'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- '**.txt'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'examples/**'
|
||||||
|
- '.github/FUNDING.yml'
|
||||||
|
- '.github/ISSUE_TEMPLATE/**'
|
||||||
|
- '.github/pull_request_template.md'
|
||||||
|
- '.gitignore'
|
||||||
|
- 'LICENSE*'
|
||||||
|
- 'ATTRIBUTION.md'
|
||||||
|
- 'SECURITY.md'
|
||||||
|
- 'CODE_OF_CONDUCT.md'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -122,6 +148,7 @@ jobs:
|
|||||||
- name: Create test report comment
|
- name: Create test report comment
|
||||||
if: github.event_name == 'pull_request' && always()
|
if: github.event_name == 'pull_request' && always()
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v7
|
||||||
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@@ -135,6 +162,7 @@ jobs:
|
|||||||
console.error('Error reading test summary:', error);
|
console.error('Error reading test summary:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// Find existing comment
|
// Find existing comment
|
||||||
const { data: comments } = await github.rest.issues.listComments({
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
@@ -164,6 +192,11 @@ jobs:
|
|||||||
body: summary
|
body: summary
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create/update PR comment:', error.message);
|
||||||
|
console.log('This is likely due to insufficient permissions for external PRs.');
|
||||||
|
console.log('Test results have been saved to the job summary instead.');
|
||||||
|
}
|
||||||
|
|
||||||
# Generate job summary
|
# Generate job summary
|
||||||
- name: Generate job summary
|
- name: Generate job summary
|
||||||
@@ -234,11 +267,13 @@ jobs:
|
|||||||
- name: Publish test results
|
- name: Publish test results
|
||||||
uses: dorny/test-reporter@v1
|
uses: dorny/test-reporter@v1
|
||||||
if: always()
|
if: always()
|
||||||
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
name: Test Results
|
name: Test Results
|
||||||
path: 'artifacts/test-results-*/test-results/junit.xml'
|
path: 'artifacts/test-results-*/test-results/junit.xml'
|
||||||
reporter: java-junit
|
reporter: java-junit
|
||||||
fail-on-error: false
|
fail-on-error: false
|
||||||
|
fail-on-empty: false
|
||||||
|
|
||||||
# Create a combined artifact with all results
|
# Create a combined artifact with all results
|
||||||
- name: Create combined results artifact
|
- name: Create combined results artifact
|
||||||
|
|||||||
@@ -180,6 +180,9 @@ The MCP server exposes tools in several categories:
|
|||||||
- Sub-agents are not allowed to spawn further sub-agents
|
- Sub-agents are not allowed to spawn further sub-agents
|
||||||
- When you use sub-agents, do not allow them to commit and push. That should be done by you
|
- When you use sub-agents, do not allow them to commit and push. That should be done by you
|
||||||
|
|
||||||
|
### Development Best Practices
|
||||||
|
- Run typecheck and lint after every code change
|
||||||
|
|
||||||
# important-instruction-reminders
|
# important-instruction-reminders
|
||||||
Do what has been asked; nothing more, nothing less.
|
Do what has been asked; nothing more, nothing less.
|
||||||
NEVER create files unless they're absolutely necessary for achieving your goal.
|
NEVER create files unless they're absolutely necessary for achieving your goal.
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
# Multi-stage Dockerfile optimized for n8n integration
|
|
||||||
# Stage 1: Build stage
|
|
||||||
FROM node:20-alpine AS builder
|
|
||||||
|
|
||||||
# Install build dependencies
|
|
||||||
RUN apk add --no-cache python3 make g++ git
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package files
|
|
||||||
COPY package*.json ./
|
|
||||||
|
|
||||||
# Install all dependencies (including dev deps for building)
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Copy source code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build the application
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Stage 2: Production stage
|
|
||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
# Install runtime dependencies
|
|
||||||
RUN apk add --no-cache \
|
|
||||||
curl \
|
|
||||||
tini \
|
|
||||||
&& rm -rf /var/cache/apk/*
|
|
||||||
|
|
||||||
# Create non-root user with unpredictable UID/GID
|
|
||||||
# Using a hash of the build time to generate unpredictable IDs
|
|
||||||
RUN BUILD_HASH=$(date +%s | sha256sum | head -c 8) && \
|
|
||||||
UID=$((10000 + 0x${BUILD_HASH} % 50000)) && \
|
|
||||||
GID=$((10000 + 0x${BUILD_HASH} % 50000)) && \
|
|
||||||
addgroup -g ${GID} n8n-mcp && \
|
|
||||||
adduser -u ${UID} -G n8n-mcp -s /bin/sh -D n8n-mcp
|
|
||||||
|
|
||||||
# Set working directory
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Copy package files (use runtime-only dependencies)
|
|
||||||
COPY package.runtime.json package.json
|
|
||||||
|
|
||||||
# Install production dependencies only
|
|
||||||
RUN npm install --production --no-audit --no-fund && \
|
|
||||||
npm cache clean --force
|
|
||||||
|
|
||||||
# Copy built application from builder stage
|
|
||||||
COPY --from=builder /app/dist ./dist
|
|
||||||
COPY --from=builder /app/data ./data
|
|
||||||
|
|
||||||
# Create necessary directories and set permissions
|
|
||||||
RUN mkdir -p /app/logs /app/data && \
|
|
||||||
chown -R n8n-mcp:n8n-mcp /app
|
|
||||||
|
|
||||||
# Switch to non-root user
|
|
||||||
USER n8n-mcp
|
|
||||||
|
|
||||||
# Set environment variables for n8n mode
|
|
||||||
ENV NODE_ENV=production \
|
|
||||||
N8N_MODE=true \
|
|
||||||
N8N_API_URL="" \
|
|
||||||
N8N_API_KEY="" \
|
|
||||||
PORT=3000
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
|
||||||
CMD curl -f http://localhost:${PORT}/health || exit 1
|
|
||||||
|
|
||||||
# Use tini for proper signal handling
|
|
||||||
ENTRYPOINT ["/sbin/tini", "--"]
|
|
||||||
|
|
||||||
# Start the application in n8n mode
|
|
||||||
CMD ["node", "dist/index.js", "n8n"]
|
|
||||||
38
README.md
38
README.md
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://github.com/czlonkowski/n8n-mcp)
|
[](https://github.com/czlonkowski/n8n-mcp)
|
||||||
[](https://github.com/czlonkowski/n8n-mcp)
|
[](https://github.com/czlonkowski/n8n-mcp)
|
||||||
[](https://www.npmjs.com/package/n8n-mcp)
|
[](https://www.npmjs.com/package/n8n-mcp)
|
||||||
[](https://codecov.io/gh/czlonkowski/n8n-mcp)
|
[](https://codecov.io/gh/czlonkowski/n8n-mcp)
|
||||||
[](https://github.com/czlonkowski/n8n-mcp/actions)
|
[](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://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp)
|
||||||
[](https://railway.com/deploy/VY6UOG?referralCode=n8n-mcp)
|
[](https://railway.com/deploy/n8n-mcp?referralCode=n8n-mcp)
|
||||||
|
|
||||||
A Model Context Protocol (MCP) server that provides AI assistants with comprehensive access to n8n node documentation, properties, and operations. Deploy in minutes to give Claude and other AI assistants deep knowledge about n8n's 525+ workflow automation nodes.
|
A Model Context Protocol (MCP) server that provides AI assistants with comprehensive access to n8n node documentation, properties, and operations. Deploy in minutes to give Claude and other AI assistants deep knowledge about n8n's 525+ workflow automation nodes.
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ A Model Context Protocol (MCP) server that provides AI assistants with comprehen
|
|||||||
|
|
||||||
n8n-MCP serves as a bridge between n8n's workflow automation platform and AI models, enabling them to understand and work with n8n nodes effectively. It provides structured access to:
|
n8n-MCP serves as a bridge between n8n's workflow automation platform and AI models, enabling them to understand and work with n8n nodes effectively. It provides structured access to:
|
||||||
|
|
||||||
- 📚 **532 n8n nodes** from both n8n-nodes-base and @n8n/n8n-nodes-langchain
|
- 📚 **535 n8n nodes** from both n8n-nodes-base and @n8n/n8n-nodes-langchain
|
||||||
- 🔧 **Node properties** - 99% coverage with detailed schemas
|
- 🔧 **Node properties** - 99% coverage with detailed schemas
|
||||||
- ⚡ **Node operations** - 63.6% coverage of available actions
|
- ⚡ **Node operations** - 63.6% coverage of available actions
|
||||||
- 📄 **Documentation** - 90% coverage from official n8n docs (including AI nodes)
|
- 📄 **Documentation** - 90% coverage from official n8n docs (including AI nodes)
|
||||||
@@ -296,7 +296,7 @@ Add to Claude Desktop config:
|
|||||||
|
|
||||||
Deploy n8n-MCP to Railway's cloud platform with zero configuration:
|
Deploy n8n-MCP to Railway's cloud platform with zero configuration:
|
||||||
|
|
||||||
[](https://railway.com/deploy/VY6UOG?referralCode=n8n-mcp)
|
[](https://railway.com/deploy/n8n-mcp?referralCode=n8n-mcp)
|
||||||
|
|
||||||
**Benefits:**
|
**Benefits:**
|
||||||
- ☁️ **Instant cloud hosting** - No server setup required
|
- ☁️ **Instant cloud hosting** - No server setup required
|
||||||
@@ -663,10 +663,10 @@ npm run dev:http # HTTP dev mode
|
|||||||
|
|
||||||
## 📊 Metrics & Coverage
|
## 📊 Metrics & Coverage
|
||||||
|
|
||||||
Current database coverage (n8n v1.103.2):
|
Current database coverage (n8n v1.106.3):
|
||||||
|
|
||||||
- ✅ **532/532** nodes loaded (100%)
|
- ✅ **535/535** nodes loaded (100%)
|
||||||
- ✅ **525** nodes with properties (98.7%)
|
- ✅ **528** nodes with properties (98.7%)
|
||||||
- ✅ **470** nodes with documentation (88%)
|
- ✅ **470** nodes with documentation (88%)
|
||||||
- ✅ **267** AI-capable tools detected
|
- ✅ **267** AI-capable tools detected
|
||||||
- ✅ **AI Agent & LangChain nodes** fully documented
|
- ✅ **AI Agent & LangChain nodes** fully documented
|
||||||
@@ -781,6 +781,26 @@ Contributions are welcome! Please:
|
|||||||
3. Run tests (`npm test`)
|
3. Run tests (`npm test`)
|
||||||
4. Submit a pull request
|
4. Submit a pull request
|
||||||
|
|
||||||
|
### 🚀 For Maintainers: Automated Releases
|
||||||
|
|
||||||
|
This project uses automated releases triggered by version changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Guided release preparation
|
||||||
|
npm run prepare:release
|
||||||
|
|
||||||
|
# Test release automation
|
||||||
|
npm run test:release-automation
|
||||||
|
```
|
||||||
|
|
||||||
|
The system automatically handles:
|
||||||
|
- 🏷️ GitHub releases with changelog content
|
||||||
|
- 📦 NPM package publishing
|
||||||
|
- 🐳 Multi-platform Docker images
|
||||||
|
- 📚 Documentation updates
|
||||||
|
|
||||||
|
See [Automated Release Guide](./docs/AUTOMATED_RELEASES.md) for complete details.
|
||||||
|
|
||||||
## 👏 Acknowledgments
|
## 👏 Acknowledgments
|
||||||
|
|
||||||
- [n8n](https://n8n.io) team for the workflow automation platform
|
- [n8n](https://n8n.io) team for the workflow automation platform
|
||||||
|
|||||||
41
_config.yml
Normal file
41
_config.yml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Jekyll configuration for GitHub Pages
|
||||||
|
# This is only used for serving benchmark results
|
||||||
|
|
||||||
|
# Only process benchmark-related files
|
||||||
|
include:
|
||||||
|
- index.html
|
||||||
|
- benchmarks/
|
||||||
|
|
||||||
|
# Exclude everything else to prevent Liquid syntax errors
|
||||||
|
exclude:
|
||||||
|
- "*.md"
|
||||||
|
- "*.json"
|
||||||
|
- "*.ts"
|
||||||
|
- "*.js"
|
||||||
|
- "*.yml"
|
||||||
|
- src/
|
||||||
|
- tests/
|
||||||
|
- docs/
|
||||||
|
- scripts/
|
||||||
|
- dist/
|
||||||
|
- node_modules/
|
||||||
|
- package.json
|
||||||
|
- package-lock.json
|
||||||
|
- tsconfig.json
|
||||||
|
- README.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
- LICENSE
|
||||||
|
- Dockerfile*
|
||||||
|
- docker-compose*
|
||||||
|
- .github/
|
||||||
|
- .vscode/
|
||||||
|
- .claude/
|
||||||
|
- deploy/
|
||||||
|
- examples/
|
||||||
|
- data/
|
||||||
|
|
||||||
|
# Disable Jekyll processing for files we don't want processed
|
||||||
|
plugins: []
|
||||||
|
|
||||||
|
# Use simple theme
|
||||||
|
theme: null
|
||||||
BIN
data/nodes.db
BIN
data/nodes.db
Binary file not shown.
@@ -22,7 +22,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- n8n-network
|
- n8n-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:5678/healthz"]
|
test: ["CMD", "sh", "-c", "wget --quiet --spider --tries=1 --timeout=10 http://localhost:5678/healthz || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -32,7 +32,7 @@ services:
|
|||||||
n8n-mcp:
|
n8n-mcp:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.n8n
|
dockerfile: Dockerfile # Uses standard Dockerfile with N8N_MODE=true env var
|
||||||
image: ghcr.io/${GITHUB_REPOSITORY:-czlonkowski/n8n-mcp}/n8n-mcp:${VERSION:-latest}
|
image: ghcr.io/${GITHUB_REPOSITORY:-czlonkowski/n8n-mcp}/n8n-mcp:${VERSION:-latest}
|
||||||
container_name: n8n-mcp
|
container_name: n8n-mcp
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -41,9 +41,11 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- N8N_MODE=true
|
- N8N_MODE=true
|
||||||
|
- MCP_MODE=http
|
||||||
- N8N_API_URL=http://n8n:5678
|
- N8N_API_URL=http://n8n:5678
|
||||||
- N8N_API_KEY=${N8N_API_KEY}
|
- N8N_API_KEY=${N8N_API_KEY}
|
||||||
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN}
|
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN}
|
||||||
|
- AUTH_TOKEN=${MCP_AUTH_TOKEN}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data:ro
|
- ./data:/app/data:ro
|
||||||
|
|||||||
384
docs/AUTOMATED_RELEASES.md
Normal file
384
docs/AUTOMATED_RELEASES.md
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
# Automated Release Process
|
||||||
|
|
||||||
|
This document describes the automated release system for n8n-mcp, which handles version detection, changelog parsing, and multi-artifact publishing.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The automated release system is triggered when the version in `package.json` is updated and pushed to the main branch. It handles:
|
||||||
|
|
||||||
|
- 🏷️ **GitHub Releases**: Creates releases with changelog content
|
||||||
|
- 📦 **NPM Publishing**: Publishes optimized runtime package
|
||||||
|
- 🐳 **Docker Images**: Builds and pushes multi-platform images
|
||||||
|
- 📚 **Documentation**: Updates version badges automatically
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### For Maintainers
|
||||||
|
|
||||||
|
Use the prepared release script for a guided experience:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run prepare:release
|
||||||
|
```
|
||||||
|
|
||||||
|
This script will:
|
||||||
|
1. Prompt for the new version
|
||||||
|
2. Update `package.json` and `package.runtime.json`
|
||||||
|
3. Update the changelog
|
||||||
|
4. Run tests and build
|
||||||
|
5. Create a git commit
|
||||||
|
6. Optionally push to trigger the release
|
||||||
|
|
||||||
|
### Manual Process
|
||||||
|
|
||||||
|
1. **Update the version**:
|
||||||
|
```bash
|
||||||
|
# Edit package.json version field
|
||||||
|
vim package.json
|
||||||
|
|
||||||
|
# Sync to runtime package
|
||||||
|
npm run sync:runtime-version
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update the changelog**:
|
||||||
|
```bash
|
||||||
|
# Edit docs/CHANGELOG.md
|
||||||
|
vim docs/CHANGELOG.md
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Test and commit**:
|
||||||
|
```bash
|
||||||
|
# Ensure everything works
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
npm run rebuild
|
||||||
|
|
||||||
|
# Commit changes
|
||||||
|
git add package.json package.runtime.json docs/CHANGELOG.md
|
||||||
|
git commit -m "chore: release vX.Y.Z"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow Details
|
||||||
|
|
||||||
|
### Version Detection
|
||||||
|
|
||||||
|
The workflow monitors pushes to the main branch and detects when `package.json` version changes:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
paths:
|
||||||
|
- 'package.json'
|
||||||
|
- 'package.runtime.json'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Changelog Parsing
|
||||||
|
|
||||||
|
Automatically extracts release notes from `docs/CHANGELOG.md` using the version header format:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## [2.10.0] - 2025-08-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- New feature descriptions
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Changed feature descriptions
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Bug fix descriptions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Release Artifacts
|
||||||
|
|
||||||
|
#### GitHub Release
|
||||||
|
- Created with extracted changelog content
|
||||||
|
- Tagged with `vX.Y.Z` format
|
||||||
|
- Includes installation instructions
|
||||||
|
- Links to documentation
|
||||||
|
|
||||||
|
#### NPM Package
|
||||||
|
- Published as `n8n-mcp` on npmjs.com
|
||||||
|
- Uses runtime-only dependencies (8 packages vs 50+ dev deps)
|
||||||
|
- Optimized for `npx` usage
|
||||||
|
- ~50MB vs 1GB+ with dev dependencies
|
||||||
|
|
||||||
|
#### Docker Images
|
||||||
|
- **Standard**: `ghcr.io/czlonkowski/n8n-mcp:vX.Y.Z`
|
||||||
|
- **Railway**: `ghcr.io/czlonkowski/n8n-mcp-railway:vX.Y.Z`
|
||||||
|
- Multi-platform: linux/amd64, linux/arm64
|
||||||
|
- Semantic version tags: `vX.Y.Z`, `vX.Y`, `vX`, `latest`
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Required Secrets
|
||||||
|
|
||||||
|
Set these in GitHub repository settings → Secrets:
|
||||||
|
|
||||||
|
| Secret | Description | Required |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| `NPM_TOKEN` | NPM authentication token for publishing | ✅ Yes |
|
||||||
|
| `GITHUB_TOKEN` | Automatically provided by GitHub Actions | ✅ Auto |
|
||||||
|
|
||||||
|
### NPM Token Setup
|
||||||
|
|
||||||
|
1. Login to [npmjs.com](https://www.npmjs.com)
|
||||||
|
2. Go to Account Settings → Access Tokens
|
||||||
|
3. Create a new **Automation** token
|
||||||
|
4. Add as `NPM_TOKEN` secret in GitHub
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test Release Automation
|
||||||
|
|
||||||
|
Validate the release system without triggering a release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test:release-automation
|
||||||
|
```
|
||||||
|
|
||||||
|
This checks:
|
||||||
|
- ✅ File existence and structure
|
||||||
|
- ✅ Version detection logic
|
||||||
|
- ✅ Changelog parsing
|
||||||
|
- ✅ Build process
|
||||||
|
- ✅ NPM package preparation
|
||||||
|
- ✅ Docker configuration
|
||||||
|
- ✅ Workflow syntax
|
||||||
|
- ✅ Environment setup
|
||||||
|
|
||||||
|
### Local Testing
|
||||||
|
|
||||||
|
Test individual components:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test version detection
|
||||||
|
node -e "console.log(require('./package.json').version)"
|
||||||
|
|
||||||
|
# Test changelog parsing
|
||||||
|
node scripts/test-release-automation.js
|
||||||
|
|
||||||
|
# Test npm package preparation
|
||||||
|
npm run prepare:publish
|
||||||
|
|
||||||
|
# Test Docker build
|
||||||
|
docker build -t test-image .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow Jobs
|
||||||
|
|
||||||
|
### 1. Version Detection
|
||||||
|
- Compares current vs previous version in git history
|
||||||
|
- Determines if it's a prerelease (alpha, beta, rc, dev)
|
||||||
|
- Outputs version information for other jobs
|
||||||
|
|
||||||
|
### 2. Changelog Extraction
|
||||||
|
- Parses `docs/CHANGELOG.md` for the current version
|
||||||
|
- Extracts content between version headers
|
||||||
|
- Provides formatted release notes
|
||||||
|
|
||||||
|
### 3. GitHub Release Creation
|
||||||
|
- Creates annotated git tag
|
||||||
|
- Creates GitHub release with changelog content
|
||||||
|
- Handles prerelease flag for alpha/beta versions
|
||||||
|
|
||||||
|
### 4. Build and Test
|
||||||
|
- Installs dependencies
|
||||||
|
- Runs full test suite
|
||||||
|
- Builds TypeScript
|
||||||
|
- Rebuilds node database
|
||||||
|
- Type checking
|
||||||
|
|
||||||
|
### 5. NPM Publishing
|
||||||
|
- Prepares optimized package structure
|
||||||
|
- Uses `package.runtime.json` for dependencies
|
||||||
|
- Publishes to npmjs.com registry
|
||||||
|
- Automatic cleanup
|
||||||
|
|
||||||
|
### 6. Docker Building
|
||||||
|
- Multi-platform builds (amd64, arm64)
|
||||||
|
- Two image variants (standard, railway)
|
||||||
|
- Semantic versioning tags
|
||||||
|
- GitHub Container Registry
|
||||||
|
|
||||||
|
### 7. Documentation Updates
|
||||||
|
- Updates version badges in README
|
||||||
|
- Commits documentation changes
|
||||||
|
- Automatic push back to repository
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### GitHub Actions
|
||||||
|
Monitor releases at: https://github.com/czlonkowski/n8n-mcp/actions
|
||||||
|
|
||||||
|
### Release Status
|
||||||
|
- **GitHub Releases**: https://github.com/czlonkowski/n8n-mcp/releases
|
||||||
|
- **NPM Package**: https://www.npmjs.com/package/n8n-mcp
|
||||||
|
- **Docker Images**: https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
|
||||||
|
The workflow provides comprehensive summaries:
|
||||||
|
- ✅ Success notifications with links
|
||||||
|
- ❌ Failure notifications with error details
|
||||||
|
- 📊 Artifact information and installation commands
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
#### NPM Publishing Fails
|
||||||
|
```
|
||||||
|
Error: 401 Unauthorized
|
||||||
|
```
|
||||||
|
**Solution**: Check NPM_TOKEN secret is valid and has publishing permissions.
|
||||||
|
|
||||||
|
#### Docker Build Fails
|
||||||
|
```
|
||||||
|
Error: failed to solve: could not read from registry
|
||||||
|
```
|
||||||
|
**Solution**: Check GitHub Container Registry permissions and GITHUB_TOKEN.
|
||||||
|
|
||||||
|
#### Changelog Parsing Fails
|
||||||
|
```
|
||||||
|
No changelog entries found for version X.Y.Z
|
||||||
|
```
|
||||||
|
**Solution**: Ensure changelog follows the correct format:
|
||||||
|
```markdown
|
||||||
|
## [X.Y.Z] - YYYY-MM-DD
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Version Detection Fails
|
||||||
|
```
|
||||||
|
Version not incremented
|
||||||
|
```
|
||||||
|
**Solution**: Ensure new version is greater than the previous version.
|
||||||
|
|
||||||
|
### Recovery Steps
|
||||||
|
|
||||||
|
#### Failed NPM Publish
|
||||||
|
1. Check if version was already published
|
||||||
|
2. If not, manually publish:
|
||||||
|
```bash
|
||||||
|
npm run prepare:publish
|
||||||
|
cd npm-publish-temp
|
||||||
|
npm publish
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Failed Docker Build
|
||||||
|
1. Build locally to test:
|
||||||
|
```bash
|
||||||
|
docker build -t test-build .
|
||||||
|
```
|
||||||
|
2. Re-trigger workflow or push a fix
|
||||||
|
|
||||||
|
#### Incomplete Release
|
||||||
|
1. Delete the created tag if needed:
|
||||||
|
```bash
|
||||||
|
git tag -d vX.Y.Z
|
||||||
|
git push --delete origin vX.Y.Z
|
||||||
|
```
|
||||||
|
2. Fix issues and push again
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### Secrets Management
|
||||||
|
- NPM_TOKEN has limited scope (publish only)
|
||||||
|
- GITHUB_TOKEN has automatic scoping
|
||||||
|
- No secrets are logged or exposed
|
||||||
|
|
||||||
|
### Package Security
|
||||||
|
- Runtime package excludes development dependencies
|
||||||
|
- No build tools or test frameworks in published package
|
||||||
|
- Minimal attack surface (~50MB vs 1GB+)
|
||||||
|
|
||||||
|
### Docker Security
|
||||||
|
- Multi-stage builds
|
||||||
|
- Non-root user execution
|
||||||
|
- Minimal base images
|
||||||
|
- Security scanning enabled
|
||||||
|
|
||||||
|
## Changelog Format
|
||||||
|
|
||||||
|
The automated system expects changelog entries in [Keep a Changelog](https://keepachangelog.com/) format:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- New features for next release
|
||||||
|
|
||||||
|
## [2.10.0] - 2025-08-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Automated release system
|
||||||
|
- Multi-platform Docker builds
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Improved version detection
|
||||||
|
- Enhanced error handling
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed changelog parsing edge cases
|
||||||
|
- Fixed Docker build optimization
|
||||||
|
|
||||||
|
## [2.9.1] - 2025-08-01
|
||||||
|
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Version Strategy
|
||||||
|
|
||||||
|
### Semantic Versioning
|
||||||
|
- **MAJOR** (X.0.0): Breaking changes
|
||||||
|
- **MINOR** (X.Y.0): New features, backward compatible
|
||||||
|
- **PATCH** (X.Y.Z): Bug fixes, backward compatible
|
||||||
|
|
||||||
|
### Prerelease Versions
|
||||||
|
- **Alpha**: `X.Y.Z-alpha.N` - Early development
|
||||||
|
- **Beta**: `X.Y.Z-beta.N` - Feature complete, testing
|
||||||
|
- **RC**: `X.Y.Z-rc.N` - Release candidate
|
||||||
|
|
||||||
|
Prerelease versions are automatically detected and marked appropriately.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Before Releasing
|
||||||
|
1. ✅ Run `npm run test:release-automation`
|
||||||
|
2. ✅ Update changelog with meaningful descriptions
|
||||||
|
3. ✅ Test locally with `npm test && npm run build`
|
||||||
|
4. ✅ Review breaking changes
|
||||||
|
5. ✅ Consider impact on users
|
||||||
|
|
||||||
|
### Version Bumping
|
||||||
|
- Use `npm run prepare:release` for guided process
|
||||||
|
- Follow semantic versioning strictly
|
||||||
|
- Document breaking changes clearly
|
||||||
|
- Consider backward compatibility
|
||||||
|
|
||||||
|
### Changelog Writing
|
||||||
|
- Be specific about changes
|
||||||
|
- Include migration notes for breaking changes
|
||||||
|
- Credit contributors
|
||||||
|
- Use consistent formatting
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
### For Maintainers
|
||||||
|
1. Use automated tools: `npm run prepare:release`
|
||||||
|
2. Follow semantic versioning
|
||||||
|
3. Update changelog thoroughly
|
||||||
|
4. Test before releasing
|
||||||
|
|
||||||
|
### For Contributors
|
||||||
|
- Breaking changes require MAJOR version bump
|
||||||
|
- New features require MINOR version bump
|
||||||
|
- Bug fixes require PATCH version bump
|
||||||
|
- Update changelog in PR descriptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
🤖 *This automated release system was designed with [Claude Code](https://claude.ai/code)*
|
||||||
@@ -5,6 +5,208 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.10.8] - 2025-09-04
|
||||||
|
|
||||||
|
### Updated
|
||||||
|
- **n8n Dependencies**: Updated to latest versions for compatibility and new features
|
||||||
|
- n8n: 1.107.4 → 1.109.2
|
||||||
|
- @n8n/n8n-nodes-langchain: 1.106.2 → 1.109.1
|
||||||
|
- n8n-nodes-base: 1.106.3 → 1.108.0 (via dependencies)
|
||||||
|
- **Node Database**: Rebuilt with 535 nodes from updated n8n packages
|
||||||
|
- **Node.js Compatibility**: Optimized for Node.js v22.17.0 LTS
|
||||||
|
- Enhanced better-sqlite3 native binary compatibility
|
||||||
|
- Fixed SQL.js fallback mode for environments without native binaries
|
||||||
|
- **CI/CD Improvements**: Fixed Rollup native module compatibility for GitHub Actions
|
||||||
|
- Added explicit platform-specific rollup binaries for cross-platform builds
|
||||||
|
- Resolved npm ci failures in Linux CI environment
|
||||||
|
- Fixed package-lock.json synchronization issues
|
||||||
|
- **Platform Support**: Enhanced cross-platform deployment compatibility
|
||||||
|
- macOS ARM64 and Linux x64 platform binaries included
|
||||||
|
- Improved npm package distribution with proper dependency resolution
|
||||||
|
- All 1,728+ tests passing with updated dependencies
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **CI/CD Pipeline**: Resolved test failures in GitHub Actions
|
||||||
|
- Fixed pyodide version conflicts between langchain dependencies
|
||||||
|
- Regenerated package-lock.json with proper dependency resolution
|
||||||
|
- Fixed Rollup native module loading in Linux CI environment
|
||||||
|
- **Database Compatibility**: Enhanced SQL.js fallback reliability
|
||||||
|
- Improved parameter binding and state management
|
||||||
|
- Fixed statement cleanup to prevent memory leaks
|
||||||
|
- **Deployment Reliability**: Better handling of platform-specific dependencies
|
||||||
|
- npm ci now works consistently across development and CI environments
|
||||||
|
|
||||||
|
## [2.10.5] - 2025-08-20
|
||||||
|
|
||||||
|
### Updated
|
||||||
|
- **n8n Dependencies**: Updated to latest versions for compatibility and new features
|
||||||
|
- n8n: 1.106.3 → 1.107.4
|
||||||
|
- n8n-core: 1.105.3 → 1.106.2
|
||||||
|
- n8n-workflow: 1.103.3 → 1.104.1
|
||||||
|
- @n8n/n8n-nodes-langchain: 1.105.3 → 1.106.2
|
||||||
|
- **Node Database**: Rebuilt with 535 nodes from updated n8n packages
|
||||||
|
- All tests passing with updated dependencies
|
||||||
|
|
||||||
|
## [2.10.4] - 2025-08-12
|
||||||
|
|
||||||
|
### Updated
|
||||||
|
- **n8n Dependencies**: Updated to latest versions for compatibility and new features
|
||||||
|
- n8n: 1.105.2 → 1.106.3
|
||||||
|
- n8n-core: 1.104.1 → 1.105.3
|
||||||
|
- n8n-workflow: 1.102.1 → 1.103.3
|
||||||
|
- @n8n/n8n-nodes-langchain: 1.104.1 → 1.105.3
|
||||||
|
- **Node Database**: Rebuilt with 535 nodes from updated n8n packages
|
||||||
|
- All 1,728 tests passing with updated dependencies
|
||||||
|
|
||||||
|
## [2.10.3] - 2025-08-07
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Validation System Robustness**: Fixed multiple critical validation issues affecting AI agents and workflow validation (fixes #58, #68, #70, #73)
|
||||||
|
- **Issue #73**: Fixed `validate_node_minimal` crash when config is undefined
|
||||||
|
- Added safe property access with optional chaining (`config?.resource`)
|
||||||
|
- Tool now handles undefined, null, and malformed configs gracefully
|
||||||
|
- **Issue #58**: Fixed `validate_node_operation` crash on invalid nodeType
|
||||||
|
- Added type checking before calling string methods
|
||||||
|
- Prevents "Cannot read properties of undefined (reading 'replace')" error
|
||||||
|
- **Issue #70**: Fixed validation profile settings being ignored
|
||||||
|
- Extended profile parameter to all validation phases (nodes, connections, expressions)
|
||||||
|
- Added Sticky Notes filtering to reduce false positives
|
||||||
|
- Enhanced cycle detection to allow legitimate loops (SplitInBatches)
|
||||||
|
- **Issue #68**: Added error recovery suggestions for AI agents
|
||||||
|
- New `addErrorRecoverySuggestions()` method provides actionable recovery steps
|
||||||
|
- Categorizes errors and suggests specific fixes for each type
|
||||||
|
- Helps AI agents self-correct when validation fails
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Input Validation System**: Comprehensive validation for all MCP tool inputs
|
||||||
|
- Created `validation-schemas.ts` with custom validation utilities
|
||||||
|
- No external dependencies - pure TypeScript implementation
|
||||||
|
- Tool-specific validation schemas for all MCP tools
|
||||||
|
- Clear error messages with field-level details
|
||||||
|
- **Enhanced Cycle Detection**: Improved detection of legitimate loops vs actual cycles
|
||||||
|
- Recognizes SplitInBatches loop patterns as valid
|
||||||
|
- Reduces false positive cycle warnings
|
||||||
|
- **Comprehensive Test Suite**: Added 16 tests covering all validation fixes
|
||||||
|
- Tests for crash prevention with malformed inputs
|
||||||
|
- Tests for profile behavior across validation phases
|
||||||
|
- Tests for error recovery suggestions
|
||||||
|
- Tests for legitimate loop patterns
|
||||||
|
|
||||||
|
### Enhanced
|
||||||
|
- **Validation Profiles**: Now consistently applied across all validation phases
|
||||||
|
- `minimal`: Reduces warnings for basic validation
|
||||||
|
- `runtime`: Standard validation for production workflows
|
||||||
|
- `ai-friendly`: Optimized for AI agent workflow creation
|
||||||
|
- `strict`: Maximum validation for critical workflows
|
||||||
|
- **Error Messages**: More helpful and actionable for both humans and AI agents
|
||||||
|
- Specific recovery suggestions for common errors
|
||||||
|
- Clear guidance on fixing validation issues
|
||||||
|
- Examples of correct configurations
|
||||||
|
|
||||||
|
## [2.10.2] - 2025-08-05
|
||||||
|
|
||||||
|
### Updated
|
||||||
|
- **n8n Dependencies**: Updated to latest versions for compatibility and new features
|
||||||
|
- n8n: 1.104.1 → 1.105.2
|
||||||
|
- n8n-core: 1.103.1 → 1.104.1
|
||||||
|
- n8n-workflow: 1.101.0 → 1.102.1
|
||||||
|
- @n8n/n8n-nodes-langchain: 1.103.1 → 1.104.1
|
||||||
|
- **Node Database**: Rebuilt with 534 nodes from updated n8n packages
|
||||||
|
- **Template Library**: Fetched 499 workflow templates from the last 12 months
|
||||||
|
- Templates are filtered to include only those created or updated within the past year
|
||||||
|
- This ensures the template library contains fresh and actively maintained workflows
|
||||||
|
- All 1,620 tests passing with updated dependencies
|
||||||
|
|
||||||
|
## [2.10.1] - 2025-08-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Memory Leak in SimpleCache**: Fixed critical memory leak causing MCP server connection loss after several hours (fixes #118)
|
||||||
|
- Added proper timer cleanup in `SimpleCache.destroy()` method
|
||||||
|
- Updated MCP server shutdown to clean up cache timers
|
||||||
|
- Enhanced HTTP server error handling with transport error handlers
|
||||||
|
- Fixed event listener cleanup to prevent accumulation
|
||||||
|
- Added comprehensive test coverage for memory leak prevention
|
||||||
|
|
||||||
|
## [2.10.0] - 2025-08-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Automated Release System**: Complete CI/CD pipeline for automated releases on version bump
|
||||||
|
- GitHub Actions workflow (`.github/workflows/release.yml`) with 7 coordinated jobs
|
||||||
|
- Automatic version detection and changelog extraction
|
||||||
|
- Multi-artifact publishing: GitHub releases, NPM package, Docker images
|
||||||
|
- Interactive release preparation tool (`npm run prepare:release`)
|
||||||
|
- Comprehensive release testing tool (`npm run test:release-automation`)
|
||||||
|
- Full documentation in `docs/AUTOMATED_RELEASES.md`
|
||||||
|
- Zero-touch releases: version bump → automatic everything
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **CI/CD Security Enhancements**:
|
||||||
|
- Replaced deprecated `actions/create-release@v1` with secure `gh` CLI
|
||||||
|
- Fixed git checkout vulnerability using safe `git show` commands
|
||||||
|
- Fixed command injection risk using proper argument arrays
|
||||||
|
- Added concurrency control to prevent simultaneous releases
|
||||||
|
- Added disk space checks before resource-intensive operations
|
||||||
|
- Implemented confirmation gates for destructive operations
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Dockerfile Consolidation**: Removed redundant `Dockerfile.n8n` in favor of single optimized `Dockerfile`
|
||||||
|
- n8n packages are not required at runtime for N8N_MODE functionality
|
||||||
|
- Standard image works perfectly with `N8N_MODE=true` environment variable
|
||||||
|
- Reduces build complexity and maintenance overhead
|
||||||
|
- Image size reduced by 500MB+ (no unnecessary n8n packages)
|
||||||
|
- Build time improved from 8+ minutes to 1-2 minutes
|
||||||
|
|
||||||
|
### Added (CI/CD Features)
|
||||||
|
- **Developer Tools**:
|
||||||
|
- `scripts/prepare-release.js`: Interactive guided release tool
|
||||||
|
- `scripts/test-release-automation.js`: Validates entire release setup
|
||||||
|
- `scripts/extract-changelog.js`: Modular changelog extraction
|
||||||
|
- **Release Automation Features**:
|
||||||
|
- NPM publishing with 3-retry mechanism for network resilience
|
||||||
|
- Multi-platform Docker builds (amd64, arm64)
|
||||||
|
- Semantic version validation and prerelease detection
|
||||||
|
- Automatic documentation badge updates
|
||||||
|
- Runtime-optimized NPM package (8 deps vs 50+, ~50MB vs 1GB+)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed missing `axios` dependency in `package.runtime.json` causing Docker build failures
|
||||||
|
|
||||||
|
## [2.9.1] - 2025-08-02
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Fixed Collection Validation**: Fixed critical issue where AI agents created invalid fixedCollection structures causing "propertyValues[itemName] is not iterable" error (fixes #90)
|
||||||
|
- Created generic `FixedCollectionValidator` utility class that handles 12 different node types
|
||||||
|
- Validates and auto-fixes common AI-generated patterns for Switch, If, Filter nodes
|
||||||
|
- Extended support to Summarize, Compare Datasets, Sort, Aggregate, Set, HTML, HTTP Request, and Airtable nodes
|
||||||
|
- Added comprehensive test coverage with 19 tests for all affected node types
|
||||||
|
- Provides clear error messages and automatic structure corrections
|
||||||
|
- **TypeScript Type Safety**: Improved type safety in fixed collection validator
|
||||||
|
- Replaced all `any` types with proper TypeScript types (`NodeConfig`, `NodeConfigValue`)
|
||||||
|
- Added type guards for safe property access
|
||||||
|
- Fixed potential memory leak in `getAllPatterns` by creating deep copies
|
||||||
|
- Added circular reference protection using `WeakSet` in structure traversal
|
||||||
|
- **Node Type Normalization**: Fixed inconsistent node type casing
|
||||||
|
- Normalized `compareDatasets` to `comparedatasets` and `httpRequest` to `httprequest`
|
||||||
|
- Ensures consistent node type handling across all validation tools
|
||||||
|
- Maintains backward compatibility with existing workflows
|
||||||
|
|
||||||
|
### Enhanced
|
||||||
|
- **Code Review Improvements**: Addressed all code review feedback
|
||||||
|
- Made output keys deterministic by removing `Math.random()` usage
|
||||||
|
- Improved error handling with comprehensive null/undefined/array checks
|
||||||
|
- Enhanced memory safety with proper object cloning
|
||||||
|
- Added protection against circular references in configuration objects
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- **Comprehensive Test Coverage**: Added extensive tests for fixedCollection validation
|
||||||
|
- 19 tests covering all 12 affected node types
|
||||||
|
- Tests for edge cases including empty configs, non-object values, and circular references
|
||||||
|
- Real-world AI agent pattern tests based on actual ChatGPT/Claude generated configs
|
||||||
|
- Version compatibility tests across all validation profiles
|
||||||
|
- TypeScript compilation tests ensuring type safety
|
||||||
|
|
||||||
## [2.9.0] - 2025-08-01
|
## [2.9.0] - 2025-08-01
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -994,6 +1196,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Basic n8n and MCP integration
|
- Basic n8n and MCP integration
|
||||||
- Core workflow automation features
|
- Core workflow automation features
|
||||||
|
|
||||||
|
[2.10.4]: https://github.com/czlonkowski/n8n-mcp/compare/v2.10.3...v2.10.4
|
||||||
|
[2.10.3]: https://github.com/czlonkowski/n8n-mcp/compare/v2.10.2...v2.10.3
|
||||||
|
[2.10.2]: https://github.com/czlonkowski/n8n-mcp/compare/v2.10.1...v2.10.2
|
||||||
|
[2.10.1]: https://github.com/czlonkowski/n8n-mcp/compare/v2.10.0...v2.10.1
|
||||||
|
[2.10.0]: https://github.com/czlonkowski/n8n-mcp/compare/v2.9.1...v2.10.0
|
||||||
|
[2.9.1]: https://github.com/czlonkowski/n8n-mcp/compare/v2.9.0...v2.9.1
|
||||||
|
[2.9.0]: https://github.com/czlonkowski/n8n-mcp/compare/v2.8.3...v2.9.0
|
||||||
|
[2.8.3]: https://github.com/czlonkowski/n8n-mcp/compare/v2.8.2...v2.8.3
|
||||||
|
[2.8.2]: https://github.com/czlonkowski/n8n-mcp/compare/v2.8.0...v2.8.2
|
||||||
|
[2.8.0]: https://github.com/czlonkowski/n8n-mcp/compare/v2.7.23...v2.8.0
|
||||||
|
[2.7.23]: https://github.com/czlonkowski/n8n-mcp/compare/v2.7.22...v2.7.23
|
||||||
[2.7.22]: https://github.com/czlonkowski/n8n-mcp/compare/v2.7.21...v2.7.22
|
[2.7.22]: https://github.com/czlonkowski/n8n-mcp/compare/v2.7.21...v2.7.22
|
||||||
[2.7.21]: https://github.com/czlonkowski/n8n-mcp/compare/v2.7.20...v2.7.21
|
[2.7.21]: https://github.com/czlonkowski/n8n-mcp/compare/v2.7.20...v2.7.21
|
||||||
[2.7.20]: https://github.com/czlonkowski/n8n-mcp/compare/v2.7.19...v2.7.20
|
[2.7.20]: https://github.com/czlonkowski/n8n-mcp/compare/v2.7.19...v2.7.20
|
||||||
|
|||||||
@@ -35,15 +35,15 @@ cd n8n-mcp
|
|||||||
npm install
|
npm install
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
# Run the test script
|
# Run the integration test script
|
||||||
./scripts/test-n8n-mode.sh
|
./scripts/test-n8n-integration.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This script will:
|
This script will:
|
||||||
1. Start n8n-MCP in n8n mode on port 3001
|
1. Start a real n8n instance in Docker
|
||||||
2. Enable debug logging for troubleshooting
|
2. Start n8n-MCP server configured for n8n
|
||||||
3. Run comprehensive protocol tests
|
3. Guide you through API key setup for workflow management
|
||||||
4. Display results and any issues found
|
4. Test the complete integration between n8n and n8n-MCP
|
||||||
|
|
||||||
### Manual Local Setup
|
### Manual Local Setup
|
||||||
|
|
||||||
@@ -57,9 +57,11 @@ For development or custom testing:
|
|||||||
```bash
|
```bash
|
||||||
# Set environment variables
|
# Set environment variables
|
||||||
export N8N_MODE=true
|
export N8N_MODE=true
|
||||||
|
export MCP_MODE=http # Required for HTTP mode
|
||||||
export N8N_API_URL=http://localhost:5678 # Your n8n instance URL
|
export N8N_API_URL=http://localhost:5678 # Your n8n instance URL
|
||||||
export N8N_API_KEY=your-api-key-here # Your n8n API key
|
export N8N_API_KEY=your-api-key-here # Your n8n API key
|
||||||
export MCP_AUTH_TOKEN=test-token-minimum-32-chars-long
|
export MCP_AUTH_TOKEN=test-token-minimum-32-chars-long
|
||||||
|
export AUTH_TOKEN=test-token-minimum-32-chars-long # Same value as MCP_AUTH_TOKEN
|
||||||
export PORT=3001
|
export PORT=3001
|
||||||
|
|
||||||
# Start the server
|
# Start the server
|
||||||
@@ -71,19 +73,56 @@ npm start
|
|||||||
# Check health
|
# Check health
|
||||||
curl http://localhost:3001/health
|
curl http://localhost:3001/health
|
||||||
|
|
||||||
# Check MCP protocol endpoint
|
# Check MCP protocol endpoint (this is the endpoint n8n connects to)
|
||||||
curl http://localhost:3001/mcp
|
curl http://localhost:3001/mcp
|
||||||
# Should return: {"protocolVersion":"2024-11-05"} for n8n compatibility
|
# Should return: {"protocolVersion":"2024-11-05"} for n8n compatibility
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Environment Variables Reference
|
||||||
|
|
||||||
|
| Variable | Required | Description | Example Value |
|
||||||
|
|----------|----------|-------------|---------------|
|
||||||
|
| `N8N_MODE` | Yes | Enables n8n integration mode | `true` |
|
||||||
|
| `MCP_MODE` | Yes | Enables HTTP mode for n8n MCP Client | `http` |
|
||||||
|
| `N8N_API_URL` | Yes* | URL of your n8n instance | `http://localhost:5678` |
|
||||||
|
| `N8N_API_KEY` | Yes* | n8n API key for workflow management | `n8n_api_xxx...` |
|
||||||
|
| `MCP_AUTH_TOKEN` | Yes | Authentication token for MCP requests (min 32 chars) | `secure-random-32-char-token` |
|
||||||
|
| `AUTH_TOKEN` | Yes | **MUST match MCP_AUTH_TOKEN exactly** | `secure-random-32-char-token` |
|
||||||
|
| `PORT` | No | Port for the HTTP server | `3000` (default) |
|
||||||
|
| `LOG_LEVEL` | No | Logging verbosity | `info`, `debug`, `error` |
|
||||||
|
|
||||||
|
*Required only for workflow management features. Documentation tools work without these.
|
||||||
|
|
||||||
|
## Docker Build Changes (v2.9.2+)
|
||||||
|
|
||||||
|
Starting with version 2.9.2, we use a single optimized Dockerfile for all deployments:
|
||||||
|
- The previous `Dockerfile.n8n` has been removed as redundant
|
||||||
|
- N8N_MODE functionality is enabled via the `N8N_MODE=true` environment variable
|
||||||
|
- This reduces image size by 500MB+ and improves build times from 8+ minutes to 1-2 minutes
|
||||||
|
- All examples now use the standard `Dockerfile`
|
||||||
|
|
||||||
## Production Deployment
|
## Production Deployment
|
||||||
|
|
||||||
|
> **⚠️ Critical**: Docker caches images locally. Always run `docker pull ghcr.io/czlonkowski/n8n-mcp:latest` before deploying to ensure you have the latest version. This simple step prevents most deployment issues.
|
||||||
|
|
||||||
### Same Server as n8n
|
### Same Server as n8n
|
||||||
|
|
||||||
If you're running n8n-MCP on the same server as your n8n instance:
|
If you're running n8n-MCP on the same server as your n8n instance:
|
||||||
|
|
||||||
1. **Using Docker** (Recommended):
|
### Using Pre-built Image (Recommended)
|
||||||
|
|
||||||
|
The pre-built images are automatically updated with each release and are the easiest way to get started.
|
||||||
|
|
||||||
|
**IMPORTANT**: Always pull the latest image to avoid using cached versions:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# ALWAYS pull the latest image first
|
||||||
|
docker pull ghcr.io/czlonkowski/n8n-mcp:latest
|
||||||
|
|
||||||
|
# Generate a secure token (save this!)
|
||||||
|
AUTH_TOKEN=$(openssl rand -hex 32)
|
||||||
|
echo "Your AUTH_TOKEN: $AUTH_TOKEN"
|
||||||
|
|
||||||
# Create a Docker network if n8n uses one
|
# Create a Docker network if n8n uses one
|
||||||
docker network create n8n-net
|
docker network create n8n-net
|
||||||
|
|
||||||
@@ -93,15 +132,42 @@ docker run -d \
|
|||||||
--network n8n-net \
|
--network n8n-net \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-e N8N_MODE=true \
|
-e N8N_MODE=true \
|
||||||
|
-e MCP_MODE=http \
|
||||||
-e N8N_API_URL=http://n8n:5678 \
|
-e N8N_API_URL=http://n8n:5678 \
|
||||||
-e N8N_API_KEY=your-n8n-api-key \
|
-e N8N_API_KEY=your-n8n-api-key \
|
||||||
-e MCP_AUTH_TOKEN=$(openssl rand -hex 32) \
|
-e MCP_AUTH_TOKEN=$AUTH_TOKEN \
|
||||||
|
-e AUTH_TOKEN=$AUTH_TOKEN \
|
||||||
-e LOG_LEVEL=info \
|
-e LOG_LEVEL=info \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
ghcr.io/czlonkowski/n8n-mcp:latest
|
ghcr.io/czlonkowski/n8n-mcp:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Using systemd** (for native installation):
|
### Building from Source (Advanced Users)
|
||||||
|
|
||||||
|
Only build from source if you need custom modifications or are contributing to development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone and build
|
||||||
|
git clone https://github.com/czlonkowski/n8n-mcp.git
|
||||||
|
cd n8n-mcp
|
||||||
|
|
||||||
|
# Build Docker image
|
||||||
|
docker build -t n8n-mcp:latest .
|
||||||
|
|
||||||
|
# Run using your local image
|
||||||
|
docker run -d \
|
||||||
|
--name n8n-mcp \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-e N8N_MODE=true \
|
||||||
|
-e MCP_MODE=http \
|
||||||
|
-e MCP_AUTH_TOKEN=$(openssl rand -hex 32) \
|
||||||
|
-e AUTH_TOKEN=$(openssl rand -hex 32) \
|
||||||
|
# ... other settings
|
||||||
|
n8n-mcp:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using systemd (for native installation)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create service file
|
# Create service file
|
||||||
sudo cat > /etc/systemd/system/n8n-mcp.service << EOF
|
sudo cat > /etc/systemd/system/n8n-mcp.service << EOF
|
||||||
@@ -114,9 +180,11 @@ Type=simple
|
|||||||
User=nodejs
|
User=nodejs
|
||||||
WorkingDirectory=/opt/n8n-mcp
|
WorkingDirectory=/opt/n8n-mcp
|
||||||
Environment="N8N_MODE=true"
|
Environment="N8N_MODE=true"
|
||||||
|
Environment="MCP_MODE=http"
|
||||||
Environment="N8N_API_URL=http://localhost:5678"
|
Environment="N8N_API_URL=http://localhost:5678"
|
||||||
Environment="N8N_API_KEY=your-n8n-api-key"
|
Environment="N8N_API_KEY=your-n8n-api-key"
|
||||||
Environment="MCP_AUTH_TOKEN=your-secure-token"
|
Environment="MCP_AUTH_TOKEN=your-secure-token-32-chars-min"
|
||||||
|
Environment="AUTH_TOKEN=your-secure-token-32-chars-min"
|
||||||
Environment="PORT=3000"
|
Environment="PORT=3000"
|
||||||
ExecStart=/usr/bin/node /opt/n8n-mcp/dist/mcp/index.js
|
ExecStart=/usr/bin/node /opt/n8n-mcp/dist/mcp/index.js
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
@@ -134,22 +202,50 @@ sudo systemctl start n8n-mcp
|
|||||||
|
|
||||||
Deploy n8n-MCP on a separate server from your n8n instance:
|
Deploy n8n-MCP on a separate server from your n8n instance:
|
||||||
|
|
||||||
#### Quick Docker Deployment
|
#### Quick Docker Deployment (Recommended)
|
||||||
|
|
||||||
|
**Always pull the latest image to ensure you have the current version:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# On your cloud server (Hetzner, AWS, DigitalOcean, etc.)
|
# On your cloud server (Hetzner, AWS, DigitalOcean, etc.)
|
||||||
|
# ALWAYS pull the latest image first
|
||||||
|
docker pull ghcr.io/czlonkowski/n8n-mcp:latest
|
||||||
|
|
||||||
|
# Generate auth tokens
|
||||||
|
AUTH_TOKEN=$(openssl rand -hex 32)
|
||||||
|
echo "Save this AUTH_TOKEN: $AUTH_TOKEN"
|
||||||
|
|
||||||
|
# Run the container
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name n8n-mcp \
|
--name n8n-mcp \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-e N8N_MODE=true \
|
-e N8N_MODE=true \
|
||||||
|
-e MCP_MODE=http \
|
||||||
-e N8N_API_URL=https://your-n8n-instance.com \
|
-e N8N_API_URL=https://your-n8n-instance.com \
|
||||||
-e N8N_API_KEY=your-n8n-api-key \
|
-e N8N_API_KEY=your-n8n-api-key \
|
||||||
-e MCP_AUTH_TOKEN=$(openssl rand -hex 32) \
|
-e MCP_AUTH_TOKEN=$AUTH_TOKEN \
|
||||||
|
-e AUTH_TOKEN=$AUTH_TOKEN \
|
||||||
-e LOG_LEVEL=info \
|
-e LOG_LEVEL=info \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
ghcr.io/czlonkowski/n8n-mcp:latest
|
ghcr.io/czlonkowski/n8n-mcp:latest
|
||||||
|
```
|
||||||
|
|
||||||
# Save the MCP_AUTH_TOKEN for later use!
|
#### Building from Source (Advanced)
|
||||||
|
|
||||||
|
Only needed if you're modifying the code:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone and build
|
||||||
|
git clone https://github.com/czlonkowski/n8n-mcp.git
|
||||||
|
cd n8n-mcp
|
||||||
|
docker build -t n8n-mcp:latest .
|
||||||
|
|
||||||
|
# Run using local image
|
||||||
|
docker run -d \
|
||||||
|
--name n8n-mcp \
|
||||||
|
-p 3000:3000 \
|
||||||
|
# ... same environment variables as above
|
||||||
|
n8n-mcp:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Full Production Setup (Hetzner/AWS/DigitalOcean)
|
#### Full Production Setup (Hetzner/AWS/DigitalOcean)
|
||||||
@@ -170,6 +266,8 @@ curl -fsSL https://get.docker.com | sh
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. **Deploy n8n-MCP with SSL** (using Caddy for automatic HTTPS):
|
3. **Deploy n8n-MCP with SSL** (using Caddy for automatic HTTPS):
|
||||||
|
|
||||||
|
**Using Docker Compose (Recommended)**
|
||||||
```bash
|
```bash
|
||||||
# Create docker-compose.yml
|
# Create docker-compose.yml
|
||||||
cat > docker-compose.yml << 'EOF'
|
cat > docker-compose.yml << 'EOF'
|
||||||
@@ -178,13 +276,16 @@ version: '3.8'
|
|||||||
services:
|
services:
|
||||||
n8n-mcp:
|
n8n-mcp:
|
||||||
image: ghcr.io/czlonkowski/n8n-mcp:latest
|
image: ghcr.io/czlonkowski/n8n-mcp:latest
|
||||||
|
pull_policy: always # Always pull latest image
|
||||||
container_name: n8n-mcp
|
container_name: n8n-mcp
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- N8N_MODE=true
|
- N8N_MODE=true
|
||||||
|
- MCP_MODE=http
|
||||||
- N8N_API_URL=${N8N_API_URL}
|
- N8N_API_URL=${N8N_API_URL}
|
||||||
- N8N_API_KEY=${N8N_API_KEY}
|
- N8N_API_KEY=${N8N_API_KEY}
|
||||||
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN}
|
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN}
|
||||||
|
- AUTH_TOKEN=${AUTH_TOKEN}
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
- LOG_LEVEL=info
|
- LOG_LEVEL=info
|
||||||
networks:
|
networks:
|
||||||
@@ -212,7 +313,59 @@ volumes:
|
|||||||
caddy_data:
|
caddy_data:
|
||||||
caddy_config:
|
caddy_config:
|
||||||
EOF
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: The `pull_policy: always` ensures you always get the latest version.
|
||||||
|
|
||||||
|
**Building from Source (if needed)**
|
||||||
|
```bash
|
||||||
|
# Only if you need custom modifications
|
||||||
|
git clone https://github.com/czlonkowski/n8n-mcp.git
|
||||||
|
cd n8n-mcp
|
||||||
|
docker build -t n8n-mcp:local .
|
||||||
|
|
||||||
|
# Then update docker-compose.yml to use:
|
||||||
|
# image: n8n-mcp:local
|
||||||
|
container_name: n8n-mcp
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- N8N_MODE=true
|
||||||
|
- MCP_MODE=http
|
||||||
|
- N8N_API_URL=${N8N_API_URL}
|
||||||
|
- N8N_API_KEY=${N8N_API_KEY}
|
||||||
|
- MCP_AUTH_TOKEN=${MCP_AUTH_TOKEN}
|
||||||
|
- AUTH_TOKEN=${AUTH_TOKEN}
|
||||||
|
- PORT=3000
|
||||||
|
- LOG_LEVEL=info
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
|
||||||
|
caddy:
|
||||||
|
image: caddy:2-alpine
|
||||||
|
container_name: caddy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
- "443:443"
|
||||||
|
volumes:
|
||||||
|
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||||
|
- caddy_data:/data
|
||||||
|
- caddy_config:/config
|
||||||
|
networks:
|
||||||
|
- web
|
||||||
|
|
||||||
|
networks:
|
||||||
|
web:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
caddy_data:
|
||||||
|
caddy_config:
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
**Complete the Setup**
|
||||||
|
```bash
|
||||||
# Create Caddyfile
|
# Create Caddyfile
|
||||||
cat > Caddyfile << 'EOF'
|
cat > Caddyfile << 'EOF'
|
||||||
mcp.yourdomain.com {
|
mcp.yourdomain.com {
|
||||||
@@ -221,15 +374,17 @@ mcp.yourdomain.com {
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Create .env file
|
# Create .env file
|
||||||
|
AUTH_TOKEN=$(openssl rand -hex 32)
|
||||||
cat > .env << EOF
|
cat > .env << EOF
|
||||||
N8N_API_URL=https://your-n8n-instance.com
|
N8N_API_URL=https://your-n8n-instance.com
|
||||||
N8N_API_KEY=your-n8n-api-key-here
|
N8N_API_KEY=your-n8n-api-key-here
|
||||||
MCP_AUTH_TOKEN=$(openssl rand -hex 32)
|
MCP_AUTH_TOKEN=$AUTH_TOKEN
|
||||||
|
AUTH_TOKEN=$AUTH_TOKEN
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Save the MCP_AUTH_TOKEN!
|
# Save the AUTH_TOKEN!
|
||||||
echo "Your MCP_AUTH_TOKEN is:"
|
echo "Your AUTH_TOKEN is: $AUTH_TOKEN"
|
||||||
grep MCP_AUTH_TOKEN .env
|
echo "Save this token - you'll need it in n8n MCP Client Tool configuration"
|
||||||
|
|
||||||
# Start services
|
# Start services
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -258,16 +413,18 @@ docker compose up -d
|
|||||||
|
|
||||||
2. **Configure the connection**:
|
2. **Configure the connection**:
|
||||||
```
|
```
|
||||||
Server URL:
|
Server URL (MUST include /mcp endpoint):
|
||||||
- Same server: http://localhost:3000
|
- Same server: http://localhost:3000/mcp
|
||||||
- Docker network: http://n8n-mcp:3000
|
- Docker network: http://n8n-mcp:3000/mcp
|
||||||
- Different server: https://mcp.yourdomain.com
|
- Different server: https://mcp.yourdomain.com/mcp
|
||||||
|
|
||||||
Auth Token: [Your MCP_AUTH_TOKEN]
|
Auth Token: [Your MCP_AUTH_TOKEN/AUTH_TOKEN value]
|
||||||
|
|
||||||
Transport: HTTP Streamable (SSE)
|
Transport: HTTP Streamable (SSE)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
⚠️ **Critical**: The Server URL must include the `/mcp` endpoint path. Without this, the connection will fail.
|
||||||
|
|
||||||
3. **Test the connection** by selecting a simple tool like `list_nodes`
|
3. **Test the connection** by selecting a simple tool like `list_nodes`
|
||||||
|
|
||||||
### Available Tools
|
### Available Tools
|
||||||
@@ -318,76 +475,270 @@ You are an n8n workflow expert. Use the MCP tools to:
|
|||||||
- **IP Whitelisting**: Consider restricting access to known n8n instances
|
- **IP Whitelisting**: Consider restricting access to known n8n instances
|
||||||
|
|
||||||
### Docker Security
|
### Docker Security
|
||||||
|
- **Always pull latest images**: Docker caches images locally, so run `docker pull` before deployment
|
||||||
- Run containers with `--read-only` flag if possible
|
- Run containers with `--read-only` flag if possible
|
||||||
- Use specific image versions instead of `:latest` in production
|
- Use specific image versions instead of `:latest` in production
|
||||||
- Regular updates: `docker pull ghcr.io/czlonkowski/n8n-mcp:latest`
|
- Regular updates: `docker pull ghcr.io/czlonkowski/n8n-mcp:latest`
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Docker Image Issues
|
||||||
|
|
||||||
|
**Using Outdated Cached Images**
|
||||||
|
- **Symptom**: Missing features, old bugs reappearing, features not working as documented
|
||||||
|
- **Cause**: Docker uses locally cached images instead of pulling the latest version
|
||||||
|
- **Solution**: Always run `docker pull ghcr.io/czlonkowski/n8n-mcp:latest` before deployment
|
||||||
|
- **Verification**: Check image age with `docker images | grep n8n-mcp`
|
||||||
|
|
||||||
|
### Common Configuration Issues
|
||||||
|
|
||||||
|
**Missing `MCP_MODE=http` Environment Variable**
|
||||||
|
- **Symptom**: n8n MCP Client Tool cannot connect, server doesn't respond on `/mcp` endpoint
|
||||||
|
- **Solution**: Add `MCP_MODE=http` to your environment variables
|
||||||
|
- **Why**: Without this, the server runs in stdio mode which is incompatible with n8n
|
||||||
|
|
||||||
|
**Server URL Missing `/mcp` Endpoint**
|
||||||
|
- **Symptom**: "Connection refused" or "Invalid response" in n8n MCP Client Tool
|
||||||
|
- **Solution**: Ensure your Server URL includes `/mcp` (e.g., `http://localhost:3000/mcp`)
|
||||||
|
- **Why**: n8n connects to the `/mcp` endpoint specifically, not the root URL
|
||||||
|
|
||||||
|
**Mismatched Auth Tokens**
|
||||||
|
- **Symptom**: "Authentication failed" or "Invalid auth token"
|
||||||
|
- **Solution**: Ensure both `MCP_AUTH_TOKEN` and `AUTH_TOKEN` have the same value
|
||||||
|
- **Why**: Both variables must match for proper authentication
|
||||||
|
|
||||||
### Connection Issues
|
### Connection Issues
|
||||||
|
|
||||||
**"Connection refused" in n8n MCP Client Tool**
|
**"Connection refused" in n8n MCP Client Tool**
|
||||||
- Check n8n-MCP is running: `docker ps` or `systemctl status n8n-mcp`
|
1. **Check n8n-MCP is running**:
|
||||||
- Verify port is accessible: `curl http://your-server:3000/health`
|
```bash
|
||||||
- Check firewall rules allow port 3000
|
# Docker
|
||||||
|
docker ps | grep n8n-mcp
|
||||||
|
docker logs n8n-mcp --tail 20
|
||||||
|
|
||||||
**"Invalid auth token"**
|
# Systemd
|
||||||
- Ensure MCP_AUTH_TOKEN matches exactly (no extra spaces)
|
systemctl status n8n-mcp
|
||||||
- Token must be at least 32 characters long
|
journalctl -u n8n-mcp --tail 20
|
||||||
- Check for special characters that might need escaping
|
```
|
||||||
|
|
||||||
|
2. **Verify endpoints are accessible**:
|
||||||
|
```bash
|
||||||
|
# Health check (should return status info)
|
||||||
|
curl http://your-server:3000/health
|
||||||
|
|
||||||
|
# MCP endpoint (should return protocol version)
|
||||||
|
curl http://your-server:3000/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Check firewall and networking**:
|
||||||
|
```bash
|
||||||
|
# Test port accessibility from n8n server
|
||||||
|
telnet your-mcp-server 3000
|
||||||
|
|
||||||
|
# Check firewall rules (Ubuntu/Debian)
|
||||||
|
sudo ufw status
|
||||||
|
|
||||||
|
# Check if port is bound correctly
|
||||||
|
netstat -tlnp | grep :3000
|
||||||
|
```
|
||||||
|
|
||||||
|
**"Invalid auth token" or "Authentication failed"**
|
||||||
|
1. **Verify token format**:
|
||||||
|
```bash
|
||||||
|
# Check token length (should be 64 chars for hex-32)
|
||||||
|
echo $MCP_AUTH_TOKEN | wc -c
|
||||||
|
|
||||||
|
# Verify both tokens match
|
||||||
|
echo "MCP_AUTH_TOKEN: $MCP_AUTH_TOKEN"
|
||||||
|
echo "AUTH_TOKEN: $AUTH_TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Common token issues**:
|
||||||
|
- Token too short (minimum 32 characters)
|
||||||
|
- Extra whitespace or newlines in token
|
||||||
|
- Different values for `MCP_AUTH_TOKEN` and `AUTH_TOKEN`
|
||||||
|
- Special characters not properly escaped in environment files
|
||||||
|
|
||||||
**"Cannot connect to n8n API"**
|
**"Cannot connect to n8n API"**
|
||||||
- Verify N8N_API_URL is correct (include http:// or https://)
|
1. **Verify n8n configuration**:
|
||||||
- Check n8n API key is valid and has necessary permissions
|
```bash
|
||||||
- Ensure n8n instance is accessible from n8n-MCP server
|
# Test n8n API accessibility
|
||||||
|
curl -H "X-N8N-API-KEY: your-api-key" \
|
||||||
|
https://your-n8n-instance.com/api/v1/workflows
|
||||||
|
```
|
||||||
|
|
||||||
### Protocol Issues
|
2. **Common n8n API issues**:
|
||||||
|
- `N8N_API_URL` missing protocol (http:// or https://)
|
||||||
|
- n8n API key expired or invalid
|
||||||
|
- n8n instance not accessible from n8n-MCP server
|
||||||
|
- n8n API disabled in settings
|
||||||
|
|
||||||
|
### Version Compatibility Issues
|
||||||
|
|
||||||
|
**"Features Not Working as Expected"**
|
||||||
|
- **Symptom**: Missing features, old bugs, or compatibility issues
|
||||||
|
- **Solution**: Pull the latest image: `docker pull ghcr.io/czlonkowski/n8n-mcp:latest`
|
||||||
|
- **Check**: Verify image date with `docker inspect ghcr.io/czlonkowski/n8n-mcp:latest | grep Created`
|
||||||
|
|
||||||
**"Protocol version mismatch"**
|
**"Protocol version mismatch"**
|
||||||
- n8n-MCP automatically uses version 2024-11-05 for n8n
|
- n8n-MCP automatically uses version 2024-11-05 for n8n compatibility
|
||||||
- Update to latest n8n-MCP version if issues persist
|
- Update to latest n8n-MCP version if issues persist
|
||||||
- Check `/mcp` endpoint returns correct version
|
- Verify `/mcp` endpoint returns correct version
|
||||||
|
|
||||||
**"Schema validation errors"**
|
### Environment Variable Issues
|
||||||
- Known issue with n8n's nested output handling
|
|
||||||
- n8n-MCP includes workarounds
|
|
||||||
- Enable debug mode to see detailed errors
|
|
||||||
|
|
||||||
### Debugging
|
**Complete Environment Variable Checklist**:
|
||||||
|
|
||||||
1. **Enable debug mode**:
|
|
||||||
```bash
|
```bash
|
||||||
|
# Required for all deployments
|
||||||
|
export N8N_MODE=true # Enables n8n integration
|
||||||
|
export MCP_MODE=http # Enables HTTP mode for n8n
|
||||||
|
export MCP_AUTH_TOKEN=your-secure-32-char-token # Auth token
|
||||||
|
export AUTH_TOKEN=your-secure-32-char-token # Same value as MCP_AUTH_TOKEN
|
||||||
|
|
||||||
|
# Required for workflow management features
|
||||||
|
export N8N_API_URL=https://your-n8n-instance.com # Your n8n URL
|
||||||
|
export N8N_API_KEY=your-n8n-api-key # Your n8n API key
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
export PORT=3000 # HTTP port (default: 3000)
|
||||||
|
export LOG_LEVEL=info # Logging level
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker-Specific Issues
|
||||||
|
|
||||||
|
**Container Build Failures**
|
||||||
|
```bash
|
||||||
|
# Clear Docker cache and rebuild
|
||||||
|
docker system prune -f
|
||||||
|
docker build --no-cache -t n8n-mcp:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
**Container Runtime Issues**
|
||||||
|
```bash
|
||||||
|
# Check container logs for detailed errors
|
||||||
|
docker logs n8n-mcp -f --timestamps
|
||||||
|
|
||||||
|
# Inspect container environment
|
||||||
|
docker exec n8n-mcp env | grep -E "(N8N|MCP|AUTH)"
|
||||||
|
|
||||||
|
# Test container connectivity
|
||||||
|
docker exec n8n-mcp curl -f http://localhost:3000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network and SSL Issues
|
||||||
|
|
||||||
|
**HTTPS/SSL Problems**
|
||||||
|
```bash
|
||||||
|
# Test SSL certificate
|
||||||
|
openssl s_client -connect mcp.yourdomain.com:443
|
||||||
|
|
||||||
|
# Check Caddy logs
|
||||||
|
docker logs caddy -f --tail 50
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker Network Issues**
|
||||||
|
```bash
|
||||||
|
# Check if containers can communicate
|
||||||
|
docker network ls
|
||||||
|
docker network inspect bridge
|
||||||
|
|
||||||
|
# Test inter-container connectivity
|
||||||
|
docker exec n8n curl http://n8n-mcp:3000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debugging Steps
|
||||||
|
|
||||||
|
1. **Enable comprehensive logging**:
|
||||||
|
```bash
|
||||||
|
# For Docker
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name n8n-mcp \
|
--name n8n-mcp \
|
||||||
-e DEBUG_MCP=true \
|
-e DEBUG_MCP=true \
|
||||||
-e LOG_LEVEL=debug \
|
-e LOG_LEVEL=debug \
|
||||||
|
-e N8N_MODE=true \
|
||||||
|
-e MCP_MODE=http \
|
||||||
# ... other settings
|
# ... other settings
|
||||||
|
|
||||||
|
# For systemd, add to service file:
|
||||||
|
Environment="DEBUG_MCP=true"
|
||||||
|
Environment="LOG_LEVEL=debug"
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Check logs**:
|
2. **Test all endpoints systematically**:
|
||||||
```bash
|
```bash
|
||||||
# Docker
|
# 1. Health check (basic server functionality)
|
||||||
docker logs n8n-mcp -f --tail 100
|
curl -v http://localhost:3000/health
|
||||||
|
|
||||||
# Systemd
|
# 2. MCP protocol endpoint (what n8n connects to)
|
||||||
journalctl -u n8n-mcp -f
|
curl -v http://localhost:3000/mcp
|
||||||
```
|
|
||||||
|
|
||||||
3. **Test endpoints**:
|
# 3. Test authentication (if working, returns tools list)
|
||||||
```bash
|
curl -X POST http://localhost:3000/mcp \
|
||||||
# Health check
|
-H "Authorization: Bearer YOUR_AUTH_TOKEN" \
|
||||||
curl http://localhost:3000/health
|
|
||||||
|
|
||||||
# Protocol version
|
|
||||||
curl http://localhost:3000/mcp
|
|
||||||
|
|
||||||
# List tools (requires auth)
|
|
||||||
curl -X POST http://localhost:3000 \
|
|
||||||
-H "Authorization: Bearer YOUR_MCP_AUTH_TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
|
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
|
||||||
|
|
||||||
|
# 4. Test a simple tool (documentation only, no n8n API needed)
|
||||||
|
curl -X POST http://localhost:3000/mcp \
|
||||||
|
-H "Authorization: Bearer YOUR_AUTH_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"get_database_statistics","arguments":{}},"id":2}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
3. **Common log patterns to look for**:
|
||||||
|
```bash
|
||||||
|
# Success patterns
|
||||||
|
grep "Server started" /var/log/n8n-mcp.log
|
||||||
|
grep "Protocol version" /var/log/n8n-mcp.log
|
||||||
|
|
||||||
|
# Error patterns
|
||||||
|
grep -i "error\|failed\|invalid" /var/log/n8n-mcp.log
|
||||||
|
grep -i "auth\|token" /var/log/n8n-mcp.log
|
||||||
|
grep -i "connection\|network" /var/log/n8n-mcp.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting Help
|
||||||
|
|
||||||
|
If you're still experiencing issues:
|
||||||
|
|
||||||
|
1. **Gather diagnostic information**:
|
||||||
|
```bash
|
||||||
|
# System info
|
||||||
|
docker --version
|
||||||
|
docker-compose --version
|
||||||
|
uname -a
|
||||||
|
|
||||||
|
# n8n-MCP version
|
||||||
|
docker exec n8n-mcp node dist/index.js --version
|
||||||
|
|
||||||
|
# Environment check
|
||||||
|
docker exec n8n-mcp env | grep -E "(N8N|MCP|AUTH)" | sort
|
||||||
|
|
||||||
|
# Container status
|
||||||
|
docker ps | grep n8n-mcp
|
||||||
|
docker stats n8n-mcp --no-stream
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create a minimal test setup**:
|
||||||
|
```bash
|
||||||
|
# Test with minimal configuration
|
||||||
|
docker run -d \
|
||||||
|
--name n8n-mcp-test \
|
||||||
|
-p 3001:3000 \
|
||||||
|
-e N8N_MODE=true \
|
||||||
|
-e MCP_MODE=http \
|
||||||
|
-e MCP_AUTH_TOKEN=test-token-minimum-32-chars-long \
|
||||||
|
-e AUTH_TOKEN=test-token-minimum-32-chars-long \
|
||||||
|
-e LOG_LEVEL=debug \
|
||||||
|
n8n-mcp:latest
|
||||||
|
|
||||||
|
# Test basic functionality
|
||||||
|
curl http://localhost:3001/health
|
||||||
|
curl http://localhost:3001/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Report issues**: Include the diagnostic information when opening an issue on [GitHub](https://github.com/czlonkowski/n8n-mcp/issues)
|
||||||
|
|
||||||
## Performance Tips
|
## Performance Tips
|
||||||
|
|
||||||
- **Minimal deployment**: 1 vCPU, 1GB RAM is sufficient
|
- **Minimal deployment**: 1 vCPU, 1GB RAM is sufficient
|
||||||
@@ -404,4 +755,4 @@ curl -X POST http://localhost:3000 \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Need help? Open an issue on [GitHub](https://github.com/czlonkowski/n8n-mcp/issues) or check the [n8n forums](https://community.n8n.io).
|
Need help? Open an issue on [GitHub](https://github.com/czlonkowski/n8n-mcp/issues) or check the [n8n forums](https://community.n8n.io)
|
||||||
@@ -106,7 +106,26 @@ These are automatically set by the Railway template:
|
|||||||
| `HOST` | `0.0.0.0` | Listen on all interfaces |
|
| `HOST` | `0.0.0.0` | Listen on all interfaces |
|
||||||
| `PORT` | (Railway provides) | Don't set manually |
|
| `PORT` | (Railway provides) | Don't set manually |
|
||||||
|
|
||||||
### Optional: n8n API Integration
|
### Optional Variables
|
||||||
|
|
||||||
|
| Variable | Default Value | Description |
|
||||||
|
|----------|--------------|-------------|
|
||||||
|
| `N8N_MODE` | `false` | Enable n8n integration mode for MCP Client Tool |
|
||||||
|
| `N8N_API_URL` | - | URL of your n8n instance (for workflow management) |
|
||||||
|
| `N8N_API_KEY` | - | API key from n8n Settings → API |
|
||||||
|
|
||||||
|
### Optional: n8n Integration
|
||||||
|
|
||||||
|
#### For n8n MCP Client Tool Integration
|
||||||
|
|
||||||
|
To use n8n-MCP with n8n's MCP Client Tool node:
|
||||||
|
|
||||||
|
1. **Go to Railway dashboard** → Your service → **Variables**
|
||||||
|
2. **Add this variable**:
|
||||||
|
- `N8N_MODE`: Set to `true` to enable n8n integration mode
|
||||||
|
3. **Save changes** - Railway will redeploy automatically
|
||||||
|
|
||||||
|
#### For n8n API Integration (Workflow Management)
|
||||||
|
|
||||||
To enable workflow management features:
|
To enable workflow management features:
|
||||||
|
|
||||||
|
|||||||
37429
package-lock.json
generated
37429
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.9.0",
|
"version": "2.10.8",
|
||||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -77,7 +77,9 @@
|
|||||||
"sync:runtime-version": "node scripts/sync-runtime-version.js",
|
"sync:runtime-version": "node scripts/sync-runtime-version.js",
|
||||||
"update:readme-version": "node scripts/update-readme-version.js",
|
"update:readme-version": "node scripts/update-readme-version.js",
|
||||||
"prepare:publish": "./scripts/publish-npm.sh",
|
"prepare:publish": "./scripts/publish-npm.sh",
|
||||||
"update:all": "./scripts/update-and-publish-prep.sh"
|
"update:all": "./scripts/update-and-publish-prep.sh",
|
||||||
|
"test:release-automation": "node scripts/test-release-automation.js",
|
||||||
|
"prepare:release": "node scripts/prepare-release.js"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -126,16 +128,18 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.13.2",
|
"@modelcontextprotocol/sdk": "^1.13.2",
|
||||||
"@n8n/n8n-nodes-langchain": "^1.103.1",
|
"@n8n/n8n-nodes-langchain": "^1.108.1",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"n8n": "^1.104.1",
|
"n8n": "^1.109.2",
|
||||||
"n8n-core": "^1.103.1",
|
"n8n-core": "^1.108.0",
|
||||||
"n8n-workflow": "^1.101.0",
|
"n8n-workflow": "^1.106.0",
|
||||||
"sql.js": "^1.13.0",
|
"sql.js": "^1.13.0",
|
||||||
"uuid": "^10.0.0"
|
"uuid": "^10.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
"@rollup/rollup-darwin-arm64": "^4.50.0",
|
||||||
|
"@rollup/rollup-linux-x64-gnu": "^4.50.0",
|
||||||
"better-sqlite3": "^11.10.0"
|
"better-sqlite3": "^11.10.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp-runtime",
|
"name": "n8n-mcp-runtime",
|
||||||
"version": "2.9.0",
|
"version": "2.10.1",
|
||||||
"description": "n8n MCP Server Runtime Dependencies Only",
|
"description": "n8n MCP Server Runtime Dependencies Only",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.13.2",
|
"@modelcontextprotocol/sdk": "^1.13.2",
|
||||||
"better-sqlite3": "^11.10.0",
|
|
||||||
"sql.js": "^1.13.0",
|
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"axios": "^1.7.2",
|
"sql.js": "^1.13.0",
|
||||||
"zod": "^3.23.8",
|
"uuid": "^10.0.0",
|
||||||
"uuid": "^10.0.0"
|
"axios": "^1.7.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
|
|||||||
@@ -1,327 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Debug script for n8n integration issues
|
|
||||||
* Tests MCP protocol compliance and identifies schema validation problems
|
|
||||||
*/
|
|
||||||
|
|
||||||
const http = require('http');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
|
|
||||||
const MCP_PORT = process.env.MCP_PORT || 3001;
|
|
||||||
const AUTH_TOKEN = process.env.AUTH_TOKEN || 'test-token-for-n8n-testing-minimum-32-chars';
|
|
||||||
|
|
||||||
console.log('🔍 Debugging n8n MCP Integration Issues');
|
|
||||||
console.log('=====================================\n');
|
|
||||||
|
|
||||||
// Test data for different MCP protocol calls
|
|
||||||
const testCases = [
|
|
||||||
{
|
|
||||||
name: 'MCP Initialize',
|
|
||||||
path: '/mcp',
|
|
||||||
method: 'POST',
|
|
||||||
data: {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
method: 'initialize',
|
|
||||||
params: {
|
|
||||||
protocolVersion: '2025-03-26',
|
|
||||||
capabilities: {
|
|
||||||
tools: {}
|
|
||||||
},
|
|
||||||
clientInfo: {
|
|
||||||
name: 'n8n-debug-test',
|
|
||||||
version: '1.0.0'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
id: 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Tools List',
|
|
||||||
path: '/mcp',
|
|
||||||
method: 'POST',
|
|
||||||
sessionId: null, // Will be set after initialize
|
|
||||||
data: {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
method: 'tools/list',
|
|
||||||
params: {},
|
|
||||||
id: 2
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Tools Call - tools_documentation',
|
|
||||||
path: '/mcp',
|
|
||||||
method: 'POST',
|
|
||||||
sessionId: null, // Will be set after initialize
|
|
||||||
data: {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
method: 'tools/call',
|
|
||||||
params: {
|
|
||||||
name: 'tools_documentation',
|
|
||||||
arguments: {}
|
|
||||||
},
|
|
||||||
id: 3
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Tools Call - get_node_essentials',
|
|
||||||
path: '/mcp',
|
|
||||||
method: 'POST',
|
|
||||||
sessionId: null, // Will be set after initialize
|
|
||||||
data: {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
method: 'tools/call',
|
|
||||||
params: {
|
|
||||||
name: 'get_node_essentials',
|
|
||||||
arguments: {
|
|
||||||
nodeType: 'nodes-base.httpRequest'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
id: 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
async function makeRequest(testCase) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = JSON.stringify(testCase.data);
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
hostname: 'localhost',
|
|
||||||
port: MCP_PORT,
|
|
||||||
path: testCase.path,
|
|
||||||
method: testCase.method,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Content-Length': Buffer.byteLength(data),
|
|
||||||
'Authorization': `Bearer ${AUTH_TOKEN}`,
|
|
||||||
'Accept': 'application/json, text/event-stream' // Fix for StreamableHTTPServerTransport
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add session ID header if available
|
|
||||||
if (testCase.sessionId) {
|
|
||||||
options.headers['Mcp-Session-Id'] = testCase.sessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`📤 Making request: ${testCase.name}`);
|
|
||||||
console.log(` Method: ${testCase.method} ${testCase.path}`);
|
|
||||||
if (testCase.sessionId) {
|
|
||||||
console.log(` Session-ID: ${testCase.sessionId}`);
|
|
||||||
}
|
|
||||||
console.log(` Data: ${data}`);
|
|
||||||
|
|
||||||
const req = http.request(options, (res) => {
|
|
||||||
let responseData = '';
|
|
||||||
|
|
||||||
console.log(`📥 Response Status: ${res.statusCode}`);
|
|
||||||
console.log(` Headers:`, res.headers);
|
|
||||||
|
|
||||||
res.on('data', (chunk) => {
|
|
||||||
responseData += chunk;
|
|
||||||
});
|
|
||||||
|
|
||||||
res.on('end', () => {
|
|
||||||
try {
|
|
||||||
let parsed;
|
|
||||||
|
|
||||||
// Handle SSE format response
|
|
||||||
if (responseData.startsWith('event: message\ndata: ')) {
|
|
||||||
const dataLine = responseData.split('\n').find(line => line.startsWith('data: '));
|
|
||||||
if (dataLine) {
|
|
||||||
const jsonData = dataLine.substring(6); // Remove 'data: '
|
|
||||||
parsed = JSON.parse(jsonData);
|
|
||||||
} else {
|
|
||||||
throw new Error('Could not extract JSON from SSE response');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
parsed = JSON.parse(responseData);
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve({
|
|
||||||
statusCode: res.statusCode,
|
|
||||||
headers: res.headers,
|
|
||||||
data: parsed,
|
|
||||||
raw: responseData
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
resolve({
|
|
||||||
statusCode: res.statusCode,
|
|
||||||
headers: res.headers,
|
|
||||||
data: null,
|
|
||||||
raw: responseData,
|
|
||||||
parseError: e.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', (err) => {
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateMCPResponse(testCase, response) {
|
|
||||||
console.log(`✅ Validating response for: ${testCase.name}`);
|
|
||||||
|
|
||||||
const issues = [];
|
|
||||||
|
|
||||||
// Check HTTP status
|
|
||||||
if (response.statusCode !== 200) {
|
|
||||||
issues.push(`❌ Expected HTTP 200, got ${response.statusCode}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check JSON-RPC structure
|
|
||||||
if (!response.data) {
|
|
||||||
issues.push(`❌ Response is not valid JSON: ${response.parseError}`);
|
|
||||||
return issues;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.data.jsonrpc !== '2.0') {
|
|
||||||
issues.push(`❌ Missing or invalid jsonrpc field: ${response.data.jsonrpc}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.data.id !== testCase.data.id) {
|
|
||||||
issues.push(`❌ ID mismatch: expected ${testCase.data.id}, got ${response.data.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method-specific validation
|
|
||||||
if (testCase.data.method === 'initialize') {
|
|
||||||
if (!response.data.result) {
|
|
||||||
issues.push(`❌ Initialize response missing result field`);
|
|
||||||
} else {
|
|
||||||
if (!response.data.result.protocolVersion) {
|
|
||||||
issues.push(`❌ Initialize response missing protocolVersion`);
|
|
||||||
} else if (response.data.result.protocolVersion !== '2025-03-26') {
|
|
||||||
issues.push(`❌ Protocol version mismatch: expected 2025-03-26, got ${response.data.result.protocolVersion}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.data.result.capabilities) {
|
|
||||||
issues.push(`❌ Initialize response missing capabilities`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.data.result.serverInfo) {
|
|
||||||
issues.push(`❌ Initialize response missing serverInfo`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract session ID for subsequent requests
|
|
||||||
if (response.headers['mcp-session-id']) {
|
|
||||||
console.log(`📋 Session ID: ${response.headers['mcp-session-id']}`);
|
|
||||||
return { issues, sessionId: response.headers['mcp-session-id'] };
|
|
||||||
} else {
|
|
||||||
issues.push(`❌ Initialize response missing Mcp-Session-Id header`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (testCase.data.method === 'tools/list') {
|
|
||||||
if (!response.data.result || !response.data.result.tools) {
|
|
||||||
issues.push(`❌ Tools list response missing tools array`);
|
|
||||||
} else {
|
|
||||||
console.log(`📋 Found ${response.data.result.tools.length} tools`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (testCase.data.method === 'tools/call') {
|
|
||||||
if (!response.data.result) {
|
|
||||||
issues.push(`❌ Tool call response missing result field`);
|
|
||||||
} else if (!response.data.result.content) {
|
|
||||||
issues.push(`❌ Tool call response missing content array`);
|
|
||||||
} else if (!Array.isArray(response.data.result.content)) {
|
|
||||||
issues.push(`❌ Tool call response content is not an array`);
|
|
||||||
} else {
|
|
||||||
// Validate content structure
|
|
||||||
for (let i = 0; i < response.data.result.content.length; i++) {
|
|
||||||
const content = response.data.result.content[i];
|
|
||||||
if (!content.type) {
|
|
||||||
issues.push(`❌ Content item ${i} missing type field`);
|
|
||||||
}
|
|
||||||
if (content.type === 'text' && !content.text) {
|
|
||||||
issues.push(`❌ Text content item ${i} missing text field`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (issues.length === 0) {
|
|
||||||
console.log(`✅ ${testCase.name} validation passed`);
|
|
||||||
} else {
|
|
||||||
console.log(`❌ ${testCase.name} validation failed:`);
|
|
||||||
issues.forEach(issue => console.log(` ${issue}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
return { issues };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runTests() {
|
|
||||||
console.log('Starting MCP protocol compliance tests...\n');
|
|
||||||
|
|
||||||
let sessionId = null;
|
|
||||||
let allIssues = [];
|
|
||||||
|
|
||||||
for (const testCase of testCases) {
|
|
||||||
try {
|
|
||||||
// Set session ID from previous test
|
|
||||||
if (sessionId && testCase.name !== 'MCP Initialize') {
|
|
||||||
testCase.sessionId = sessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await makeRequest(testCase);
|
|
||||||
console.log(`📄 Raw Response: ${response.raw}\n`);
|
|
||||||
|
|
||||||
const validation = await validateMCPResponse(testCase, response);
|
|
||||||
|
|
||||||
if (validation.sessionId) {
|
|
||||||
sessionId = validation.sessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
allIssues.push(...validation.issues);
|
|
||||||
|
|
||||||
console.log('─'.repeat(50));
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ Request failed for ${testCase.name}:`, error.message);
|
|
||||||
allIssues.push(`Request failed for ${testCase.name}: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary
|
|
||||||
console.log('\n📊 SUMMARY');
|
|
||||||
console.log('==========');
|
|
||||||
|
|
||||||
if (allIssues.length === 0) {
|
|
||||||
console.log('🎉 All tests passed! MCP protocol compliance looks good.');
|
|
||||||
} else {
|
|
||||||
console.log(`❌ Found ${allIssues.length} issues:`);
|
|
||||||
allIssues.forEach((issue, i) => {
|
|
||||||
console.log(` ${i + 1}. ${issue}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('\n🔍 Recommendations:');
|
|
||||||
console.log('1. Check MCP server logs at /tmp/mcp-server.log');
|
|
||||||
console.log('2. Verify protocol version consistency (should be 2025-03-26)');
|
|
||||||
console.log('3. Ensure tool schemas match MCP specification exactly');
|
|
||||||
console.log('4. Test with actual n8n MCP Client Tool node');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if MCP server is running
|
|
||||||
console.log(`Checking if MCP server is running at localhost:${MCP_PORT}...`);
|
|
||||||
|
|
||||||
const healthCheck = http.get(`http://localhost:${MCP_PORT}/health`, (res) => {
|
|
||||||
if (res.statusCode === 200) {
|
|
||||||
console.log('✅ MCP server is running\n');
|
|
||||||
runTests().catch(console.error);
|
|
||||||
} else {
|
|
||||||
console.error('❌ MCP server health check failed:', res.statusCode);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}).on('error', (err) => {
|
|
||||||
console.error('❌ MCP server is not running. Please start it first:', err.message);
|
|
||||||
console.error('Use: npm run start:n8n');
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
84
scripts/extract-changelog.js
Executable file
84
scripts/extract-changelog.js
Executable file
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract changelog content for a specific version
|
||||||
|
* Used by GitHub Actions to extract release notes
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function extractChangelog(version, changelogPath) {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(changelogPath)) {
|
||||||
|
console.error(`Changelog file not found at ${changelogPath}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(changelogPath, 'utf8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
// Find the start of this version's section
|
||||||
|
const versionHeaderRegex = new RegExp(`^## \\[${version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`);
|
||||||
|
let startIndex = -1;
|
||||||
|
let endIndex = -1;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (versionHeaderRegex.test(lines[i])) {
|
||||||
|
startIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startIndex === -1) {
|
||||||
|
console.error(`No changelog entries found for version ${version}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the end of this version's section (next version or end of file)
|
||||||
|
for (let i = startIndex + 1; i < lines.length; i++) {
|
||||||
|
if (lines[i].startsWith('## [') && !lines[i].includes('Unreleased')) {
|
||||||
|
endIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endIndex === -1) {
|
||||||
|
endIndex = lines.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the section content
|
||||||
|
const sectionLines = lines.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
// Remove the version header and any trailing empty lines
|
||||||
|
let contentLines = sectionLines.slice(1);
|
||||||
|
while (contentLines.length > 0 && contentLines[contentLines.length - 1].trim() === '') {
|
||||||
|
contentLines.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentLines.length === 0) {
|
||||||
|
console.error(`No content found for version ${version}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseNotes = contentLines.join('\n').trim();
|
||||||
|
|
||||||
|
// Write to stdout for GitHub Actions
|
||||||
|
console.log(releaseNotes);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error extracting changelog: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse command line arguments
|
||||||
|
const version = process.argv[2];
|
||||||
|
const changelogPath = process.argv[3];
|
||||||
|
|
||||||
|
if (!version || !changelogPath) {
|
||||||
|
console.error('Usage: extract-changelog.js <version> <changelog-path>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
extractChangelog(version, changelogPath);
|
||||||
400
scripts/prepare-release.js
Executable file
400
scripts/prepare-release.js
Executable file
@@ -0,0 +1,400 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-release preparation script
|
||||||
|
* Validates and prepares everything needed for a successful release
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execSync, spawnSync } = require('child_process');
|
||||||
|
const readline = require('readline');
|
||||||
|
|
||||||
|
// Color codes
|
||||||
|
const colors = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
red: '\x1b[31m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
blue: '\x1b[34m',
|
||||||
|
magenta: '\x1b[35m',
|
||||||
|
cyan: '\x1b[36m'
|
||||||
|
};
|
||||||
|
|
||||||
|
function log(message, color = 'reset') {
|
||||||
|
console.log(`${colors[color]}${message}${colors.reset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function success(message) {
|
||||||
|
log(`✅ ${message}`, 'green');
|
||||||
|
}
|
||||||
|
|
||||||
|
function warning(message) {
|
||||||
|
log(`⚠️ ${message}`, 'yellow');
|
||||||
|
}
|
||||||
|
|
||||||
|
function error(message) {
|
||||||
|
log(`❌ ${message}`, 'red');
|
||||||
|
}
|
||||||
|
|
||||||
|
function info(message) {
|
||||||
|
log(`ℹ️ ${message}`, 'blue');
|
||||||
|
}
|
||||||
|
|
||||||
|
function header(title) {
|
||||||
|
log(`\n${'='.repeat(60)}`, 'cyan');
|
||||||
|
log(`🚀 ${title}`, 'cyan');
|
||||||
|
log(`${'='.repeat(60)}`, 'cyan');
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReleasePreparation {
|
||||||
|
constructor() {
|
||||||
|
this.rootDir = path.resolve(__dirname, '..');
|
||||||
|
this.rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async askQuestion(question) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.rl.question(question, resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current version and ask for new version
|
||||||
|
*/
|
||||||
|
async getVersionInfo() {
|
||||||
|
const packageJson = require(path.join(this.rootDir, 'package.json'));
|
||||||
|
const currentVersion = packageJson.version;
|
||||||
|
|
||||||
|
log(`\nCurrent version: ${currentVersion}`, 'blue');
|
||||||
|
|
||||||
|
const newVersion = await this.askQuestion('\nEnter new version (e.g., 2.10.0): ');
|
||||||
|
|
||||||
|
if (!newVersion || !this.isValidSemver(newVersion)) {
|
||||||
|
error('Invalid semantic version format');
|
||||||
|
throw new Error('Invalid version');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.compareVersions(newVersion, currentVersion) <= 0) {
|
||||||
|
error('New version must be greater than current version');
|
||||||
|
throw new Error('Version not incremented');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { currentVersion, newVersion };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate semantic version format (strict semver compliance)
|
||||||
|
*/
|
||||||
|
isValidSemver(version) {
|
||||||
|
// Strict semantic versioning regex
|
||||||
|
const semverRegex = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
|
||||||
|
return semverRegex.test(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two semantic versions
|
||||||
|
*/
|
||||||
|
compareVersions(v1, v2) {
|
||||||
|
const parseVersion = (v) => v.split('-')[0].split('.').map(Number);
|
||||||
|
const [v1Parts, v2Parts] = [parseVersion(v1), parseVersion(v2)];
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if (v1Parts[i] > v2Parts[i]) return 1;
|
||||||
|
if (v1Parts[i] < v2Parts[i]) return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update version in package files
|
||||||
|
*/
|
||||||
|
updateVersions(newVersion) {
|
||||||
|
log('\n📝 Updating version in package files...', 'blue');
|
||||||
|
|
||||||
|
// Update package.json
|
||||||
|
const packageJsonPath = path.join(this.rootDir, 'package.json');
|
||||||
|
const packageJson = require(packageJsonPath);
|
||||||
|
packageJson.version = newVersion;
|
||||||
|
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n');
|
||||||
|
success('Updated package.json');
|
||||||
|
|
||||||
|
// Sync to runtime package
|
||||||
|
try {
|
||||||
|
execSync('npm run sync:runtime-version', { cwd: this.rootDir, stdio: 'pipe' });
|
||||||
|
success('Synced package.runtime.json');
|
||||||
|
} catch (err) {
|
||||||
|
warning('Could not sync runtime version automatically');
|
||||||
|
|
||||||
|
// Manual sync
|
||||||
|
const runtimeJsonPath = path.join(this.rootDir, 'package.runtime.json');
|
||||||
|
if (fs.existsSync(runtimeJsonPath)) {
|
||||||
|
const runtimeJson = require(runtimeJsonPath);
|
||||||
|
runtimeJson.version = newVersion;
|
||||||
|
fs.writeFileSync(runtimeJsonPath, JSON.stringify(runtimeJson, null, 2) + '\n');
|
||||||
|
success('Manually synced package.runtime.json');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update changelog
|
||||||
|
*/
|
||||||
|
async updateChangelog(newVersion) {
|
||||||
|
const changelogPath = path.join(this.rootDir, 'docs/CHANGELOG.md');
|
||||||
|
|
||||||
|
if (!fs.existsSync(changelogPath)) {
|
||||||
|
warning('Changelog file not found, skipping update');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log('\n📋 Updating changelog...', 'blue');
|
||||||
|
|
||||||
|
const content = fs.readFileSync(changelogPath, 'utf8');
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Check if version already exists in changelog
|
||||||
|
const versionRegex = new RegExp(`^## \\[${newVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`, 'm');
|
||||||
|
if (versionRegex.test(content)) {
|
||||||
|
info(`Version ${newVersion} already exists in changelog`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the Unreleased section
|
||||||
|
const unreleasedMatch = content.match(/^## \[Unreleased\]\s*\n([\s\S]*?)(?=\n## \[|$)/m);
|
||||||
|
|
||||||
|
if (unreleasedMatch) {
|
||||||
|
const unreleasedContent = unreleasedMatch[1].trim();
|
||||||
|
|
||||||
|
if (unreleasedContent) {
|
||||||
|
log('\nFound content in Unreleased section:', 'blue');
|
||||||
|
log(unreleasedContent.substring(0, 200) + '...', 'yellow');
|
||||||
|
|
||||||
|
const moveContent = await this.askQuestion('\nMove this content to the new version? (y/n): ');
|
||||||
|
|
||||||
|
if (moveContent.toLowerCase() === 'y') {
|
||||||
|
// Move unreleased content to new version
|
||||||
|
const newVersionSection = `## [${newVersion}] - ${today}\n\n${unreleasedContent}\n\n`;
|
||||||
|
const updatedContent = content.replace(
|
||||||
|
/^## \[Unreleased\]\s*\n[\s\S]*?(?=\n## \[)/m,
|
||||||
|
`## [Unreleased]\n\n${newVersionSection}## [`
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(changelogPath, updatedContent);
|
||||||
|
success(`Moved unreleased content to version ${newVersion}`);
|
||||||
|
} else {
|
||||||
|
// Just add empty version section
|
||||||
|
const newVersionSection = `## [${newVersion}] - ${today}\n\n### Added\n- \n\n### Changed\n- \n\n### Fixed\n- \n\n`;
|
||||||
|
const updatedContent = content.replace(
|
||||||
|
/^## \[Unreleased\]\s*\n/m,
|
||||||
|
`## [Unreleased]\n\n${newVersionSection}`
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(changelogPath, updatedContent);
|
||||||
|
warning(`Added empty version section for ${newVersion} - please fill in the changes`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Add empty version section
|
||||||
|
const newVersionSection = `## [${newVersion}] - ${today}\n\n### Added\n- \n\n### Changed\n- \n\n### Fixed\n- \n\n`;
|
||||||
|
const updatedContent = content.replace(
|
||||||
|
/^## \[Unreleased\]\s*\n/m,
|
||||||
|
`## [Unreleased]\n\n${newVersionSection}`
|
||||||
|
);
|
||||||
|
|
||||||
|
fs.writeFileSync(changelogPath, updatedContent);
|
||||||
|
warning(`Added empty version section for ${newVersion} - please fill in the changes`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warning('Could not find Unreleased section in changelog');
|
||||||
|
}
|
||||||
|
|
||||||
|
info('Please review and edit the changelog before committing');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run tests and build
|
||||||
|
*/
|
||||||
|
async runChecks() {
|
||||||
|
log('\n🧪 Running pre-release checks...', 'blue');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Run tests
|
||||||
|
log('Running tests...', 'blue');
|
||||||
|
execSync('npm test', { cwd: this.rootDir, stdio: 'inherit' });
|
||||||
|
success('All tests passed');
|
||||||
|
|
||||||
|
// Run build
|
||||||
|
log('Building project...', 'blue');
|
||||||
|
execSync('npm run build', { cwd: this.rootDir, stdio: 'inherit' });
|
||||||
|
success('Build completed');
|
||||||
|
|
||||||
|
// Rebuild database
|
||||||
|
log('Rebuilding database...', 'blue');
|
||||||
|
execSync('npm run rebuild', { cwd: this.rootDir, stdio: 'inherit' });
|
||||||
|
success('Database rebuilt');
|
||||||
|
|
||||||
|
// Run type checking
|
||||||
|
log('Type checking...', 'blue');
|
||||||
|
execSync('npm run typecheck', { cwd: this.rootDir, stdio: 'inherit' });
|
||||||
|
success('Type checking passed');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
error('Pre-release checks failed');
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create git commit
|
||||||
|
*/
|
||||||
|
async createCommit(newVersion) {
|
||||||
|
log('\n📝 Creating git commit...', 'blue');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check git status
|
||||||
|
const status = execSync('git status --porcelain', {
|
||||||
|
cwd: this.rootDir,
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!status.trim()) {
|
||||||
|
info('No changes to commit');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show what will be committed
|
||||||
|
log('\nFiles to be committed:', 'blue');
|
||||||
|
execSync('git diff --name-only', { cwd: this.rootDir, stdio: 'inherit' });
|
||||||
|
|
||||||
|
const commit = await this.askQuestion('\nCreate commit for release? (y/n): ');
|
||||||
|
|
||||||
|
if (commit.toLowerCase() === 'y') {
|
||||||
|
// Add files
|
||||||
|
execSync('git add package.json package.runtime.json docs/CHANGELOG.md', {
|
||||||
|
cwd: this.rootDir,
|
||||||
|
stdio: 'pipe'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create commit
|
||||||
|
const commitMessage = `chore: release v${newVersion}
|
||||||
|
|
||||||
|
🤖 Generated with [Claude Code](https://claude.ai/code)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude <noreply@anthropic.com>`;
|
||||||
|
|
||||||
|
const result = spawnSync('git', ['commit', '-m', commitMessage], {
|
||||||
|
cwd: this.rootDir,
|
||||||
|
stdio: 'pipe',
|
||||||
|
encoding: 'utf8'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.error || result.status !== 0) {
|
||||||
|
throw new Error(`Git commit failed: ${result.stderr || result.error?.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
success(`Created commit for v${newVersion}`);
|
||||||
|
|
||||||
|
const push = await this.askQuestion('\nPush to trigger release workflow? (y/n): ');
|
||||||
|
|
||||||
|
if (push.toLowerCase() === 'y') {
|
||||||
|
// Add confirmation for destructive operation
|
||||||
|
warning('\n⚠️ DESTRUCTIVE OPERATION WARNING ⚠️');
|
||||||
|
warning('This will trigger a PUBLIC RELEASE that cannot be undone!');
|
||||||
|
warning('The following will happen automatically:');
|
||||||
|
warning('• Create GitHub release with tag');
|
||||||
|
warning('• Publish package to NPM registry');
|
||||||
|
warning('• Build and push Docker images');
|
||||||
|
warning('• Update documentation');
|
||||||
|
|
||||||
|
const confirmation = await this.askQuestion('\nType "RELEASE" (all caps) to confirm: ');
|
||||||
|
|
||||||
|
if (confirmation === 'RELEASE') {
|
||||||
|
execSync('git push', { cwd: this.rootDir, stdio: 'inherit' });
|
||||||
|
success('Pushed to remote repository');
|
||||||
|
log('\n🎉 Release workflow will be triggered automatically!', 'green');
|
||||||
|
log('Monitor progress at: https://github.com/czlonkowski/n8n-mcp/actions', 'blue');
|
||||||
|
} else {
|
||||||
|
warning('Release cancelled. Commit created but not pushed.');
|
||||||
|
info('You can push manually later to trigger the release.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
info('Commit created but not pushed. Push manually to trigger release.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
error(`Git operations failed: ${err.message}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display final instructions
|
||||||
|
*/
|
||||||
|
displayInstructions(newVersion) {
|
||||||
|
header('Release Preparation Complete');
|
||||||
|
|
||||||
|
log('📋 What happens next:', 'blue');
|
||||||
|
log(`1. The GitHub Actions workflow will detect the version change to v${newVersion}`, 'green');
|
||||||
|
log('2. It will automatically:', 'green');
|
||||||
|
log(' • Create a GitHub release with changelog content', 'green');
|
||||||
|
log(' • Publish the npm package', 'green');
|
||||||
|
log(' • Build and push Docker images', 'green');
|
||||||
|
log(' • Update documentation badges', 'green');
|
||||||
|
log('\n🔍 Monitor the release at:', 'blue');
|
||||||
|
log(' • GitHub Actions: https://github.com/czlonkowski/n8n-mcp/actions', 'blue');
|
||||||
|
log(' • NPM Package: https://www.npmjs.com/package/n8n-mcp', 'blue');
|
||||||
|
log(' • Docker Images: https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp', 'blue');
|
||||||
|
|
||||||
|
log('\n✅ Release preparation completed successfully!', 'green');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main execution flow
|
||||||
|
*/
|
||||||
|
async run() {
|
||||||
|
try {
|
||||||
|
header('n8n-MCP Release Preparation');
|
||||||
|
|
||||||
|
// Get version information
|
||||||
|
const { currentVersion, newVersion } = await this.getVersionInfo();
|
||||||
|
|
||||||
|
log(`\n🔄 Preparing release: ${currentVersion} → ${newVersion}`, 'magenta');
|
||||||
|
|
||||||
|
// Update versions
|
||||||
|
this.updateVersions(newVersion);
|
||||||
|
|
||||||
|
// Update changelog
|
||||||
|
await this.updateChangelog(newVersion);
|
||||||
|
|
||||||
|
// Run pre-release checks
|
||||||
|
await this.runChecks();
|
||||||
|
|
||||||
|
// Create git commit
|
||||||
|
await this.createCommit(newVersion);
|
||||||
|
|
||||||
|
// Display final instructions
|
||||||
|
this.displayInstructions(newVersion);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
error(`Release preparation failed: ${err.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
this.rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the script
|
||||||
|
if (require.main === module) {
|
||||||
|
const preparation = new ReleasePreparation();
|
||||||
|
preparation.run().catch(err => {
|
||||||
|
console.error('Release preparation failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ReleasePreparation;
|
||||||
@@ -10,7 +10,7 @@ import { getToolDocumentation } from '../src/mcp/tools-documentation';
|
|||||||
import { ExampleGenerator } from '../src/services/example-generator';
|
import { ExampleGenerator } from '../src/services/example-generator';
|
||||||
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator';
|
import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator';
|
||||||
|
|
||||||
const dbPath = process.env.NODE_DB_PATH || './nodes.db';
|
const dbPath = process.env.NODE_DB_PATH || './data/nodes.db';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('🧪 Testing Code Node Documentation Fixes\n');
|
console.log('🧪 Testing Code Node Documentation Fixes\n');
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Test script for n8n MCP integration fixes
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "🔧 Testing n8n MCP Integration Fixes"
|
|
||||||
echo "===================================="
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
MCP_PORT=${MCP_PORT:-3001}
|
|
||||||
AUTH_TOKEN=${AUTH_TOKEN:-"test-token-for-n8n-testing-minimum-32-chars"}
|
|
||||||
|
|
||||||
# Colors
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
BLUE='\033[0;34m'
|
|
||||||
NC='\033[0m' # No Color
|
|
||||||
|
|
||||||
# Cleanup function
|
|
||||||
cleanup() {
|
|
||||||
echo -e "\n${YELLOW}🧹 Cleaning up...${NC}"
|
|
||||||
if [ -n "$MCP_PID" ] && kill -0 $MCP_PID 2>/dev/null; then
|
|
||||||
echo "Stopping MCP server..."
|
|
||||||
kill $MCP_PID 2>/dev/null || true
|
|
||||||
wait $MCP_PID 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
echo -e "${GREEN}✅ Cleanup complete${NC}"
|
|
||||||
}
|
|
||||||
|
|
||||||
trap cleanup EXIT INT TERM
|
|
||||||
|
|
||||||
# Check if we're in the right directory
|
|
||||||
if [ ! -f "package.json" ] || [ ! -d "dist" ]; then
|
|
||||||
echo -e "${RED}❌ Error: Must run from n8n-mcp directory${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build the project (our fixes)
|
|
||||||
echo -e "${YELLOW}📦 Building project with fixes...${NC}"
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Start MCP server in n8n mode
|
|
||||||
echo -e "\n${GREEN}🚀 Starting MCP server in n8n mode...${NC}"
|
|
||||||
N8N_MODE=true \
|
|
||||||
MCP_MODE=http \
|
|
||||||
AUTH_TOKEN="${AUTH_TOKEN}" \
|
|
||||||
PORT=${MCP_PORT} \
|
|
||||||
DEBUG_MCP=true \
|
|
||||||
node dist/mcp/index.js > /tmp/mcp-n8n-test.log 2>&1 &
|
|
||||||
|
|
||||||
MCP_PID=$!
|
|
||||||
echo -e "${YELLOW}📄 MCP server logs: /tmp/mcp-n8n-test.log${NC}"
|
|
||||||
|
|
||||||
# Wait for server to start
|
|
||||||
echo -e "${YELLOW}⏳ Waiting for MCP server to start...${NC}"
|
|
||||||
for i in {1..15}; do
|
|
||||||
if curl -s http://localhost:${MCP_PORT}/health >/dev/null 2>&1; then
|
|
||||||
echo -e "${GREEN}✅ MCP server is ready!${NC}"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if [ $i -eq 15 ]; then
|
|
||||||
echo -e "${RED}❌ MCP server failed to start${NC}"
|
|
||||||
echo "Server logs:"
|
|
||||||
cat /tmp/mcp-n8n-test.log
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
# Test the protocol fixes
|
|
||||||
echo -e "\n${BLUE}🧪 Testing protocol fixes...${NC}"
|
|
||||||
|
|
||||||
# Run our debug script
|
|
||||||
echo -e "${YELLOW}Running comprehensive MCP protocol tests...${NC}"
|
|
||||||
node scripts/debug-n8n-mode.js
|
|
||||||
|
|
||||||
echo -e "\n${GREEN}🎉 Test complete!${NC}"
|
|
||||||
echo -e "\n📋 Summary of fixes applied:"
|
|
||||||
echo -e " ✅ Fixed protocol version mismatch (now using 2025-03-26)"
|
|
||||||
echo -e " ✅ Enhanced tool response formatting and size validation"
|
|
||||||
echo -e " ✅ Added comprehensive parameter validation"
|
|
||||||
echo -e " ✅ Improved error handling and logging"
|
|
||||||
echo -e " ✅ Added initialization request debugging"
|
|
||||||
|
|
||||||
echo -e "\n📝 Next steps:"
|
|
||||||
echo -e " 1. If tests pass, the n8n schema validation errors should be resolved"
|
|
||||||
echo -e " 2. Test with actual n8n MCP Client Tool node"
|
|
||||||
echo -e " 3. Monitor logs at /tmp/mcp-n8n-test.log for any remaining issues"
|
|
||||||
|
|
||||||
echo -e "\n${YELLOW}Press any key to view recent server logs, or Ctrl+C to exit...${NC}"
|
|
||||||
read -n 1
|
|
||||||
|
|
||||||
echo -e "\n${BLUE}📄 Recent server logs:${NC}"
|
|
||||||
tail -50 /tmp/mcp-n8n-test.log
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
#!/usr/bin/env ts-node
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TypeScript test script for n8n MCP integration fixes
|
|
||||||
* Tests the protocol changes and identifies any remaining issues
|
|
||||||
*/
|
|
||||||
|
|
||||||
import http from 'http';
|
|
||||||
import { spawn, ChildProcess } from 'child_process';
|
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
interface TestResult {
|
|
||||||
name: string;
|
|
||||||
passed: boolean;
|
|
||||||
error?: string;
|
|
||||||
response?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
class N8nMcpTester {
|
|
||||||
private mcpProcess: ChildProcess | null = null;
|
|
||||||
private readonly mcpPort = 3001;
|
|
||||||
private readonly authToken = 'test-token-for-n8n-testing-minimum-32-chars';
|
|
||||||
private sessionId: string | null = null;
|
|
||||||
|
|
||||||
async start(): Promise<void> {
|
|
||||||
console.log('🔧 Testing n8n MCP Integration Fixes');
|
|
||||||
console.log('====================================\n');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.startMcpServer();
|
|
||||||
await this.runTests();
|
|
||||||
} finally {
|
|
||||||
await this.cleanup();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async startMcpServer(): Promise<void> {
|
|
||||||
console.log('📦 Starting MCP server in n8n mode...');
|
|
||||||
|
|
||||||
const projectRoot = path.resolve(__dirname, '..');
|
|
||||||
|
|
||||||
this.mcpProcess = spawn('node', ['dist/mcp/index.js'], {
|
|
||||||
cwd: projectRoot,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
N8N_MODE: 'true',
|
|
||||||
MCP_MODE: 'http',
|
|
||||||
AUTH_TOKEN: this.authToken,
|
|
||||||
PORT: this.mcpPort.toString(),
|
|
||||||
DEBUG_MCP: 'true'
|
|
||||||
},
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe']
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log server output
|
|
||||||
this.mcpProcess.stdout?.on('data', (data) => {
|
|
||||||
console.log(`[MCP] ${data.toString().trim()}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.mcpProcess.stderr?.on('data', (data) => {
|
|
||||||
console.error(`[MCP ERROR] ${data.toString().trim()}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for server to be ready
|
|
||||||
await this.waitForServer();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async waitForServer(): Promise<void> {
|
|
||||||
console.log('⏳ Waiting for MCP server to be ready...');
|
|
||||||
|
|
||||||
for (let i = 0; i < 30; i++) {
|
|
||||||
try {
|
|
||||||
await this.makeHealthCheck();
|
|
||||||
console.log('✅ MCP server is ready!\n');
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
if (i === 29) {
|
|
||||||
throw new Error('MCP server failed to start within 30 seconds');
|
|
||||||
}
|
|
||||||
await this.sleep(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private makeHealthCheck(): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const req = http.get(`http://localhost:${this.mcpPort}/health`, (res) => {
|
|
||||||
if (res.statusCode === 200) {
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
reject(new Error(`Health check failed: ${res.statusCode}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', reject);
|
|
||||||
req.setTimeout(5000, () => {
|
|
||||||
req.destroy();
|
|
||||||
reject(new Error('Health check timeout'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async runTests(): Promise<void> {
|
|
||||||
const tests: TestResult[] = [];
|
|
||||||
|
|
||||||
// Test 1: Initialize with correct protocol version
|
|
||||||
tests.push(await this.testInitialize());
|
|
||||||
|
|
||||||
// Test 2: List tools
|
|
||||||
tests.push(await this.testListTools());
|
|
||||||
|
|
||||||
// Test 3: Call tools_documentation
|
|
||||||
tests.push(await this.testToolCall('tools_documentation', {}));
|
|
||||||
|
|
||||||
// Test 4: Call get_node_essentials with parameters
|
|
||||||
tests.push(await this.testToolCall('get_node_essentials', {
|
|
||||||
nodeType: 'nodes-base.httpRequest'
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Test 5: Call with invalid parameters (should handle gracefully)
|
|
||||||
tests.push(await this.testToolCallInvalid());
|
|
||||||
|
|
||||||
this.printResults(tests);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async testInitialize(): Promise<TestResult> {
|
|
||||||
console.log('🧪 Testing MCP Initialize...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.makeRequest('POST', '/mcp', {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
method: 'initialize',
|
|
||||||
params: {
|
|
||||||
protocolVersion: '2025-03-26',
|
|
||||||
capabilities: { tools: {} },
|
|
||||||
clientInfo: { name: 'n8n-test', version: '1.0.0' }
|
|
||||||
},
|
|
||||||
id: 1
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.statusCode !== 200) {
|
|
||||||
return {
|
|
||||||
name: 'Initialize',
|
|
||||||
passed: false,
|
|
||||||
error: `HTTP ${response.statusCode}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(response.body);
|
|
||||||
|
|
||||||
// Extract session ID
|
|
||||||
this.sessionId = response.headers['mcp-session-id'] as string;
|
|
||||||
|
|
||||||
if (data.result?.protocolVersion === '2025-03-26') {
|
|
||||||
return {
|
|
||||||
name: 'Initialize',
|
|
||||||
passed: true,
|
|
||||||
response: data
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
name: 'Initialize',
|
|
||||||
passed: false,
|
|
||||||
error: `Wrong protocol version: ${data.result?.protocolVersion}`,
|
|
||||||
response: data
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
name: 'Initialize',
|
|
||||||
passed: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async testListTools(): Promise<TestResult> {
|
|
||||||
console.log('🧪 Testing Tools List...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.makeRequest('POST', '/mcp', {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
method: 'tools/list',
|
|
||||||
params: {},
|
|
||||||
id: 2
|
|
||||||
}, this.sessionId);
|
|
||||||
|
|
||||||
if (response.statusCode !== 200) {
|
|
||||||
return {
|
|
||||||
name: 'List Tools',
|
|
||||||
passed: false,
|
|
||||||
error: `HTTP ${response.statusCode}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(response.body);
|
|
||||||
|
|
||||||
if (data.result?.tools && Array.isArray(data.result.tools)) {
|
|
||||||
return {
|
|
||||||
name: 'List Tools',
|
|
||||||
passed: true,
|
|
||||||
response: { toolCount: data.result.tools.length }
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
name: 'List Tools',
|
|
||||||
passed: false,
|
|
||||||
error: 'Missing or invalid tools array',
|
|
||||||
response: data
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
name: 'List Tools',
|
|
||||||
passed: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async testToolCall(toolName: string, args: any): Promise<TestResult> {
|
|
||||||
console.log(`🧪 Testing Tool Call: ${toolName}...`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.makeRequest('POST', '/mcp', {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
method: 'tools/call',
|
|
||||||
params: {
|
|
||||||
name: toolName,
|
|
||||||
arguments: args
|
|
||||||
},
|
|
||||||
id: 3
|
|
||||||
}, this.sessionId);
|
|
||||||
|
|
||||||
if (response.statusCode !== 200) {
|
|
||||||
return {
|
|
||||||
name: `Tool Call: ${toolName}`,
|
|
||||||
passed: false,
|
|
||||||
error: `HTTP ${response.statusCode}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(response.body);
|
|
||||||
|
|
||||||
if (data.result?.content && Array.isArray(data.result.content)) {
|
|
||||||
return {
|
|
||||||
name: `Tool Call: ${toolName}`,
|
|
||||||
passed: true,
|
|
||||||
response: { contentItems: data.result.content.length }
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
name: `Tool Call: ${toolName}`,
|
|
||||||
passed: false,
|
|
||||||
error: 'Missing or invalid content array',
|
|
||||||
response: data
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
name: `Tool Call: ${toolName}`,
|
|
||||||
passed: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async testToolCallInvalid(): Promise<TestResult> {
|
|
||||||
console.log('🧪 Testing Tool Call with invalid parameters...');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await this.makeRequest('POST', '/mcp', {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
method: 'tools/call',
|
|
||||||
params: {
|
|
||||||
name: 'get_node_essentials',
|
|
||||||
arguments: {} // Missing required nodeType parameter
|
|
||||||
},
|
|
||||||
id: 4
|
|
||||||
}, this.sessionId);
|
|
||||||
|
|
||||||
if (response.statusCode !== 200) {
|
|
||||||
return {
|
|
||||||
name: 'Tool Call: Invalid Params',
|
|
||||||
passed: false,
|
|
||||||
error: `HTTP ${response.statusCode}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(response.body);
|
|
||||||
|
|
||||||
// Should either return an error response or handle gracefully
|
|
||||||
if (data.error || (data.result?.isError && data.result?.content)) {
|
|
||||||
return {
|
|
||||||
name: 'Tool Call: Invalid Params',
|
|
||||||
passed: true,
|
|
||||||
response: { handledGracefully: true }
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
name: 'Tool Call: Invalid Params',
|
|
||||||
passed: false,
|
|
||||||
error: 'Did not handle invalid parameters properly',
|
|
||||||
response: data
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
name: 'Tool Call: Invalid Params',
|
|
||||||
passed: false,
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private makeRequest(method: string, path: string, data?: any, sessionId?: string | null): Promise<{
|
|
||||||
statusCode: number;
|
|
||||||
headers: http.IncomingHttpHeaders;
|
|
||||||
body: string;
|
|
||||||
}> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const postData = data ? JSON.stringify(data) : '';
|
|
||||||
|
|
||||||
const options: http.RequestOptions = {
|
|
||||||
hostname: 'localhost',
|
|
||||||
port: this.mcpPort,
|
|
||||||
path,
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${this.authToken}`,
|
|
||||||
...(postData && { 'Content-Length': Buffer.byteLength(postData) }),
|
|
||||||
...(sessionId && { 'Mcp-Session-Id': sessionId })
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const req = http.request(options, (res) => {
|
|
||||||
let body = '';
|
|
||||||
res.on('data', (chunk) => body += chunk);
|
|
||||||
res.on('end', () => {
|
|
||||||
resolve({
|
|
||||||
statusCode: res.statusCode || 0,
|
|
||||||
headers: res.headers,
|
|
||||||
body
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
req.on('error', reject);
|
|
||||||
req.setTimeout(10000, () => {
|
|
||||||
req.destroy();
|
|
||||||
reject(new Error('Request timeout'));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (postData) {
|
|
||||||
req.write(postData);
|
|
||||||
}
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private printResults(tests: TestResult[]): void {
|
|
||||||
console.log('\n📊 TEST RESULTS');
|
|
||||||
console.log('================');
|
|
||||||
|
|
||||||
const passed = tests.filter(t => t.passed).length;
|
|
||||||
const total = tests.length;
|
|
||||||
|
|
||||||
tests.forEach(test => {
|
|
||||||
const status = test.passed ? '✅' : '❌';
|
|
||||||
console.log(`${status} ${test.name}`);
|
|
||||||
if (!test.passed && test.error) {
|
|
||||||
console.log(` Error: ${test.error}`);
|
|
||||||
}
|
|
||||||
if (test.response) {
|
|
||||||
console.log(` Response: ${JSON.stringify(test.response, null, 2)}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`\n📈 Summary: ${passed}/${total} tests passed`);
|
|
||||||
|
|
||||||
if (passed === total) {
|
|
||||||
console.log('🎉 All tests passed! The n8n integration fixes should resolve the schema validation errors.');
|
|
||||||
} else {
|
|
||||||
console.log('❌ Some tests failed. Please review the errors above.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async cleanup(): Promise<void> {
|
|
||||||
console.log('\n🧹 Cleaning up...');
|
|
||||||
|
|
||||||
if (this.mcpProcess) {
|
|
||||||
this.mcpProcess.kill('SIGTERM');
|
|
||||||
|
|
||||||
// Wait for graceful shutdown
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
if (!this.mcpProcess) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
this.mcpProcess?.kill('SIGKILL');
|
|
||||||
resolve();
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
this.mcpProcess.on('exit', () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ Cleanup complete');
|
|
||||||
}
|
|
||||||
|
|
||||||
private sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the tests
|
|
||||||
if (require.main === module) {
|
|
||||||
const tester = new N8nMcpTester();
|
|
||||||
tester.start().catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { N8nMcpTester };
|
|
||||||
560
scripts/test-release-automation.js
Executable file
560
scripts/test-release-automation.js
Executable file
@@ -0,0 +1,560 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test script for release automation
|
||||||
|
* Validates the release workflow components locally
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
// Color codes for output
|
||||||
|
const colors = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
red: '\x1b[31m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
blue: '\x1b[34m',
|
||||||
|
magenta: '\x1b[35m',
|
||||||
|
cyan: '\x1b[36m'
|
||||||
|
};
|
||||||
|
|
||||||
|
function log(message, color = 'reset') {
|
||||||
|
console.log(`${colors[color]}${message}${colors.reset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function header(title) {
|
||||||
|
log(`\n${'='.repeat(60)}`, 'cyan');
|
||||||
|
log(`🧪 ${title}`, 'cyan');
|
||||||
|
log(`${'='.repeat(60)}`, 'cyan');
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(title) {
|
||||||
|
log(`\n📋 ${title}`, 'blue');
|
||||||
|
log(`${'-'.repeat(40)}`, 'blue');
|
||||||
|
}
|
||||||
|
|
||||||
|
function success(message) {
|
||||||
|
log(`✅ ${message}`, 'green');
|
||||||
|
}
|
||||||
|
|
||||||
|
function warning(message) {
|
||||||
|
log(`⚠️ ${message}`, 'yellow');
|
||||||
|
}
|
||||||
|
|
||||||
|
function error(message) {
|
||||||
|
log(`❌ ${message}`, 'red');
|
||||||
|
}
|
||||||
|
|
||||||
|
function info(message) {
|
||||||
|
log(`ℹ️ ${message}`, 'blue');
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReleaseAutomationTester {
|
||||||
|
constructor() {
|
||||||
|
this.rootDir = path.resolve(__dirname, '..');
|
||||||
|
this.errors = [];
|
||||||
|
this.warnings = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test if required files exist
|
||||||
|
*/
|
||||||
|
testFileExistence() {
|
||||||
|
section('Testing File Existence');
|
||||||
|
|
||||||
|
const requiredFiles = [
|
||||||
|
'package.json',
|
||||||
|
'package.runtime.json',
|
||||||
|
'docs/CHANGELOG.md',
|
||||||
|
'.github/workflows/release.yml',
|
||||||
|
'scripts/sync-runtime-version.js',
|
||||||
|
'scripts/publish-npm.sh'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const file of requiredFiles) {
|
||||||
|
const filePath = path.join(this.rootDir, file);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
success(`Found: ${file}`);
|
||||||
|
} else {
|
||||||
|
error(`Missing: ${file}`);
|
||||||
|
this.errors.push(`Missing required file: ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test version detection logic
|
||||||
|
*/
|
||||||
|
testVersionDetection() {
|
||||||
|
section('Testing Version Detection');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const packageJson = require(path.join(this.rootDir, 'package.json'));
|
||||||
|
const runtimeJson = require(path.join(this.rootDir, 'package.runtime.json'));
|
||||||
|
|
||||||
|
success(`Package.json version: ${packageJson.version}`);
|
||||||
|
success(`Runtime package version: ${runtimeJson.version}`);
|
||||||
|
|
||||||
|
if (packageJson.version === runtimeJson.version) {
|
||||||
|
success('Version sync: Both versions match');
|
||||||
|
} else {
|
||||||
|
warning('Version sync: Versions do not match - run sync:runtime-version');
|
||||||
|
this.warnings.push('Package versions are not synchronized');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test semantic version format
|
||||||
|
const semverRegex = /^\d+\.\d+\.\d+(?:-[\w\.-]+)?(?:\+[\w\.-]+)?$/;
|
||||||
|
if (semverRegex.test(packageJson.version)) {
|
||||||
|
success(`Version format: Valid semantic version (${packageJson.version})`);
|
||||||
|
} else {
|
||||||
|
error(`Version format: Invalid semantic version (${packageJson.version})`);
|
||||||
|
this.errors.push('Invalid semantic version format');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
error(`Version detection failed: ${err.message}`);
|
||||||
|
this.errors.push(`Version detection error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test changelog parsing
|
||||||
|
*/
|
||||||
|
testChangelogParsing() {
|
||||||
|
section('Testing Changelog Parsing');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const changelogPath = path.join(this.rootDir, 'docs/CHANGELOG.md');
|
||||||
|
|
||||||
|
if (!fs.existsSync(changelogPath)) {
|
||||||
|
error('Changelog file not found');
|
||||||
|
this.errors.push('Missing changelog file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changelogContent = fs.readFileSync(changelogPath, 'utf8');
|
||||||
|
const packageJson = require(path.join(this.rootDir, 'package.json'));
|
||||||
|
const currentVersion = packageJson.version;
|
||||||
|
|
||||||
|
// Check if current version exists in changelog
|
||||||
|
const versionRegex = new RegExp(`^## \\[${currentVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`, 'm');
|
||||||
|
|
||||||
|
if (versionRegex.test(changelogContent)) {
|
||||||
|
success(`Changelog entry found for version ${currentVersion}`);
|
||||||
|
|
||||||
|
// Test extraction logic (simplified version of the GitHub Actions script)
|
||||||
|
const lines = changelogContent.split('\n');
|
||||||
|
let startIndex = -1;
|
||||||
|
let endIndex = -1;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (versionRegex.test(lines[i])) {
|
||||||
|
startIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startIndex !== -1) {
|
||||||
|
// Find the end of this version's section
|
||||||
|
for (let i = startIndex + 1; i < lines.length; i++) {
|
||||||
|
if (lines[i].startsWith('## [') && !lines[i].includes('Unreleased')) {
|
||||||
|
endIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endIndex === -1) {
|
||||||
|
endIndex = lines.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionLines = lines.slice(startIndex + 1, endIndex);
|
||||||
|
const contentLines = sectionLines.filter(line => line.trim() !== '');
|
||||||
|
|
||||||
|
if (contentLines.length > 0) {
|
||||||
|
success(`Changelog content extracted: ${contentLines.length} lines`);
|
||||||
|
info(`Preview: ${contentLines[0].substring(0, 100)}...`);
|
||||||
|
} else {
|
||||||
|
warning('Changelog section appears to be empty');
|
||||||
|
this.warnings.push(`Empty changelog section for version ${currentVersion}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
warning(`No changelog entry found for current version ${currentVersion}`);
|
||||||
|
this.warnings.push(`Missing changelog entry for version ${currentVersion}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check changelog format
|
||||||
|
if (changelogContent.includes('## [Unreleased]')) {
|
||||||
|
success('Changelog format: Contains Unreleased section');
|
||||||
|
} else {
|
||||||
|
warning('Changelog format: Missing Unreleased section');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changelogContent.includes('Keep a Changelog')) {
|
||||||
|
success('Changelog format: Follows Keep a Changelog format');
|
||||||
|
} else {
|
||||||
|
warning('Changelog format: Does not reference Keep a Changelog');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
error(`Changelog parsing failed: ${err.message}`);
|
||||||
|
this.errors.push(`Changelog parsing error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test build process
|
||||||
|
*/
|
||||||
|
testBuildProcess() {
|
||||||
|
section('Testing Build Process');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if dist directory exists
|
||||||
|
const distPath = path.join(this.rootDir, 'dist');
|
||||||
|
if (fs.existsSync(distPath)) {
|
||||||
|
success('Build output: dist directory exists');
|
||||||
|
|
||||||
|
// Check for key build files
|
||||||
|
const keyFiles = [
|
||||||
|
'dist/index.js',
|
||||||
|
'dist/mcp/index.js',
|
||||||
|
'dist/mcp/server.js'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const file of keyFiles) {
|
||||||
|
const filePath = path.join(this.rootDir, file);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
success(`Build file: ${file} exists`);
|
||||||
|
} else {
|
||||||
|
warning(`Build file: ${file} missing - run 'npm run build'`);
|
||||||
|
this.warnings.push(`Missing build file: ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
warning('Build output: dist directory missing - run "npm run build"');
|
||||||
|
this.warnings.push('Missing build output');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check database
|
||||||
|
const dbPath = path.join(this.rootDir, 'data/nodes.db');
|
||||||
|
if (fs.existsSync(dbPath)) {
|
||||||
|
const stats = fs.statSync(dbPath);
|
||||||
|
success(`Database: nodes.db exists (${Math.round(stats.size / 1024 / 1024)}MB)`);
|
||||||
|
} else {
|
||||||
|
warning('Database: nodes.db missing - run "npm run rebuild"');
|
||||||
|
this.warnings.push('Missing database file');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
error(`Build process test failed: ${err.message}`);
|
||||||
|
this.errors.push(`Build process error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test npm publish preparation
|
||||||
|
*/
|
||||||
|
testNpmPublishPrep() {
|
||||||
|
section('Testing NPM Publish Preparation');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const packageJson = require(path.join(this.rootDir, 'package.json'));
|
||||||
|
const runtimeJson = require(path.join(this.rootDir, 'package.runtime.json'));
|
||||||
|
|
||||||
|
// Check package.json fields
|
||||||
|
const requiredFields = ['name', 'version', 'description', 'main', 'bin'];
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (packageJson[field]) {
|
||||||
|
success(`Package field: ${field} is present`);
|
||||||
|
} else {
|
||||||
|
error(`Package field: ${field} is missing`);
|
||||||
|
this.errors.push(`Missing package.json field: ${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check runtime dependencies
|
||||||
|
if (runtimeJson.dependencies) {
|
||||||
|
const depCount = Object.keys(runtimeJson.dependencies).length;
|
||||||
|
success(`Runtime dependencies: ${depCount} packages`);
|
||||||
|
|
||||||
|
// List key dependencies
|
||||||
|
const keyDeps = ['@modelcontextprotocol/sdk', 'express', 'sql.js'];
|
||||||
|
for (const dep of keyDeps) {
|
||||||
|
if (runtimeJson.dependencies[dep]) {
|
||||||
|
success(`Key dependency: ${dep} (${runtimeJson.dependencies[dep]})`);
|
||||||
|
} else {
|
||||||
|
warning(`Key dependency: ${dep} is missing`);
|
||||||
|
this.warnings.push(`Missing key dependency: ${dep}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
error('Runtime package has no dependencies');
|
||||||
|
this.errors.push('Missing runtime dependencies');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check files array
|
||||||
|
if (packageJson.files && Array.isArray(packageJson.files)) {
|
||||||
|
success(`Package files: ${packageJson.files.length} patterns specified`);
|
||||||
|
info(`Files: ${packageJson.files.join(', ')}`);
|
||||||
|
} else {
|
||||||
|
warning('Package files: No files array specified');
|
||||||
|
this.warnings.push('No files array in package.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
error(`NPM publish prep test failed: ${err.message}`);
|
||||||
|
this.errors.push(`NPM publish prep error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test Docker configuration
|
||||||
|
*/
|
||||||
|
testDockerConfig() {
|
||||||
|
section('Testing Docker Configuration');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dockerfiles = ['Dockerfile', 'Dockerfile.railway'];
|
||||||
|
|
||||||
|
for (const dockerfile of dockerfiles) {
|
||||||
|
const dockerfilePath = path.join(this.rootDir, dockerfile);
|
||||||
|
if (fs.existsSync(dockerfilePath)) {
|
||||||
|
success(`Dockerfile: ${dockerfile} exists`);
|
||||||
|
|
||||||
|
const content = fs.readFileSync(dockerfilePath, 'utf8');
|
||||||
|
|
||||||
|
// Check for key instructions
|
||||||
|
if (content.includes('FROM node:')) {
|
||||||
|
success(`${dockerfile}: Uses Node.js base image`);
|
||||||
|
} else {
|
||||||
|
warning(`${dockerfile}: Does not use standard Node.js base image`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.includes('COPY dist')) {
|
||||||
|
success(`${dockerfile}: Copies build output`);
|
||||||
|
} else {
|
||||||
|
warning(`${dockerfile}: May not copy build output correctly`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
warning(`Dockerfile: ${dockerfile} not found`);
|
||||||
|
this.warnings.push(`Missing Dockerfile: ${dockerfile}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check docker-compose files
|
||||||
|
const composeFiles = ['docker-compose.yml', 'docker-compose.n8n.yml'];
|
||||||
|
for (const composeFile of composeFiles) {
|
||||||
|
const composePath = path.join(this.rootDir, composeFile);
|
||||||
|
if (fs.existsSync(composePath)) {
|
||||||
|
success(`Docker Compose: ${composeFile} exists`);
|
||||||
|
} else {
|
||||||
|
info(`Docker Compose: ${composeFile} not found (optional)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
error(`Docker config test failed: ${err.message}`);
|
||||||
|
this.errors.push(`Docker config error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test workflow file syntax
|
||||||
|
*/
|
||||||
|
testWorkflowSyntax() {
|
||||||
|
section('Testing Workflow Syntax');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const workflowPath = path.join(this.rootDir, '.github/workflows/release.yml');
|
||||||
|
|
||||||
|
if (!fs.existsSync(workflowPath)) {
|
||||||
|
error('Release workflow file not found');
|
||||||
|
this.errors.push('Missing release workflow file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowContent = fs.readFileSync(workflowPath, 'utf8');
|
||||||
|
|
||||||
|
// Basic YAML structure checks
|
||||||
|
if (workflowContent.includes('name: Automated Release')) {
|
||||||
|
success('Workflow: Has correct name');
|
||||||
|
} else {
|
||||||
|
warning('Workflow: Name may be incorrect');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflowContent.includes('on:') && workflowContent.includes('push:')) {
|
||||||
|
success('Workflow: Has push trigger');
|
||||||
|
} else {
|
||||||
|
error('Workflow: Missing push trigger');
|
||||||
|
this.errors.push('Workflow missing push trigger');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflowContent.includes('branches: [main]')) {
|
||||||
|
success('Workflow: Configured for main branch');
|
||||||
|
} else {
|
||||||
|
warning('Workflow: May not be configured for main branch');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for required jobs
|
||||||
|
const requiredJobs = [
|
||||||
|
'detect-version-change',
|
||||||
|
'extract-changelog',
|
||||||
|
'create-release',
|
||||||
|
'publish-npm',
|
||||||
|
'build-docker'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const job of requiredJobs) {
|
||||||
|
if (workflowContent.includes(`${job}:`)) {
|
||||||
|
success(`Workflow job: ${job} defined`);
|
||||||
|
} else {
|
||||||
|
error(`Workflow job: ${job} missing`);
|
||||||
|
this.errors.push(`Missing workflow job: ${job}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for secrets usage
|
||||||
|
if (workflowContent.includes('${{ secrets.NPM_TOKEN }}')) {
|
||||||
|
success('Workflow: NPM_TOKEN secret configured');
|
||||||
|
} else {
|
||||||
|
warning('Workflow: NPM_TOKEN secret may be missing');
|
||||||
|
this.warnings.push('NPM_TOKEN secret may need to be configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workflowContent.includes('${{ secrets.GITHUB_TOKEN }}')) {
|
||||||
|
success('Workflow: GITHUB_TOKEN secret configured');
|
||||||
|
} else {
|
||||||
|
warning('Workflow: GITHUB_TOKEN secret may be missing');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
error(`Workflow syntax test failed: ${err.message}`);
|
||||||
|
this.errors.push(`Workflow syntax error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test environment and dependencies
|
||||||
|
*/
|
||||||
|
testEnvironment() {
|
||||||
|
section('Testing Environment');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check Node.js version
|
||||||
|
const nodeVersion = process.version;
|
||||||
|
success(`Node.js version: ${nodeVersion}`);
|
||||||
|
|
||||||
|
// Check if npm is available
|
||||||
|
try {
|
||||||
|
const npmVersion = execSync('npm --version', { encoding: 'utf8', stdio: 'pipe' }).trim();
|
||||||
|
success(`NPM version: ${npmVersion}`);
|
||||||
|
} catch (err) {
|
||||||
|
error('NPM not available');
|
||||||
|
this.errors.push('NPM not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if git is available
|
||||||
|
try {
|
||||||
|
const gitVersion = execSync('git --version', { encoding: 'utf8', stdio: 'pipe' }).trim();
|
||||||
|
success(`Git available: ${gitVersion}`);
|
||||||
|
} catch (err) {
|
||||||
|
error('Git not available');
|
||||||
|
this.errors.push('Git not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're in a git repository
|
||||||
|
try {
|
||||||
|
execSync('git rev-parse --git-dir', { stdio: 'pipe' });
|
||||||
|
success('Git repository: Detected');
|
||||||
|
|
||||||
|
// Check current branch
|
||||||
|
try {
|
||||||
|
const branch = execSync('git branch --show-current', { encoding: 'utf8', stdio: 'pipe' }).trim();
|
||||||
|
info(`Current branch: ${branch}`);
|
||||||
|
} catch (err) {
|
||||||
|
info('Could not determine current branch');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
warning('Not in a git repository');
|
||||||
|
this.warnings.push('Not in a git repository');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
error(`Environment test failed: ${err.message}`);
|
||||||
|
this.errors.push(`Environment error: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run all tests
|
||||||
|
*/
|
||||||
|
async runAllTests() {
|
||||||
|
header('Release Automation Test Suite');
|
||||||
|
|
||||||
|
info('Testing release automation components...');
|
||||||
|
|
||||||
|
this.testFileExistence();
|
||||||
|
this.testVersionDetection();
|
||||||
|
this.testChangelogParsing();
|
||||||
|
this.testBuildProcess();
|
||||||
|
this.testNpmPublishPrep();
|
||||||
|
this.testDockerConfig();
|
||||||
|
this.testWorkflowSyntax();
|
||||||
|
this.testEnvironment();
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
header('Test Summary');
|
||||||
|
|
||||||
|
if (this.errors.length === 0 && this.warnings.length === 0) {
|
||||||
|
log('🎉 All tests passed! Release automation is ready.', 'green');
|
||||||
|
} else {
|
||||||
|
if (this.errors.length > 0) {
|
||||||
|
log(`\n❌ ${this.errors.length} Error(s):`, 'red');
|
||||||
|
this.errors.forEach(err => log(` • ${err}`, 'red'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.warnings.length > 0) {
|
||||||
|
log(`\n⚠️ ${this.warnings.length} Warning(s):`, 'yellow');
|
||||||
|
this.warnings.forEach(warn => log(` • ${warn}`, 'yellow'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.errors.length > 0) {
|
||||||
|
log('\n🔧 Please fix the errors before running the release workflow.', 'red');
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
log('\n✅ No critical errors found. Warnings should be reviewed but won\'t prevent releases.', 'yellow');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next steps
|
||||||
|
log('\n📋 Next Steps:', 'cyan');
|
||||||
|
log('1. Ensure all secrets are configured in GitHub repository settings:', 'cyan');
|
||||||
|
log(' • NPM_TOKEN (required for npm publishing)', 'cyan');
|
||||||
|
log(' • GITHUB_TOKEN (automatically available)', 'cyan');
|
||||||
|
log('\n2. To trigger a release:', 'cyan');
|
||||||
|
log(' • Update version in package.json', 'cyan');
|
||||||
|
log(' • Update changelog in docs/CHANGELOG.md', 'cyan');
|
||||||
|
log(' • Commit and push to main branch', 'cyan');
|
||||||
|
log('\n3. Monitor the release workflow in GitHub Actions', 'cyan');
|
||||||
|
|
||||||
|
return this.errors.length === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the tests
|
||||||
|
if (require.main === module) {
|
||||||
|
const tester = new ReleaseAutomationTester();
|
||||||
|
tester.runAllTests().catch(err => {
|
||||||
|
console.error('Test suite failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ReleaseAutomationTester;
|
||||||
@@ -376,27 +376,36 @@ class SQLJSStatement implements PreparedStatement {
|
|||||||
constructor(private stmt: any, private onModify: () => void) {}
|
constructor(private stmt: any, private onModify: () => void) {}
|
||||||
|
|
||||||
run(...params: any[]): RunResult {
|
run(...params: any[]): RunResult {
|
||||||
|
try {
|
||||||
if (params.length > 0) {
|
if (params.length > 0) {
|
||||||
this.bindParams(params);
|
this.bindParams(params);
|
||||||
|
if (this.boundParams) {
|
||||||
this.stmt.bind(this.boundParams);
|
this.stmt.bind(this.boundParams);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.stmt.run();
|
this.stmt.run();
|
||||||
this.onModify();
|
this.onModify();
|
||||||
|
|
||||||
// sql.js doesn't provide changes/lastInsertRowid easily
|
// sql.js doesn't provide changes/lastInsertRowid easily
|
||||||
return {
|
return {
|
||||||
changes: 0,
|
changes: 1, // Assume success means 1 change
|
||||||
lastInsertRowid: 0
|
lastInsertRowid: 0
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.stmt.reset();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get(...params: any[]): any {
|
get(...params: any[]): any {
|
||||||
|
try {
|
||||||
if (params.length > 0) {
|
if (params.length > 0) {
|
||||||
this.bindParams(params);
|
this.bindParams(params);
|
||||||
}
|
if (this.boundParams) {
|
||||||
|
|
||||||
this.stmt.bind(this.boundParams);
|
this.stmt.bind(this.boundParams);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.stmt.step()) {
|
if (this.stmt.step()) {
|
||||||
const result = this.stmt.getAsObject();
|
const result = this.stmt.getAsObject();
|
||||||
@@ -406,14 +415,20 @@ class SQLJSStatement implements PreparedStatement {
|
|||||||
|
|
||||||
this.stmt.reset();
|
this.stmt.reset();
|
||||||
return undefined;
|
return undefined;
|
||||||
|
} catch (error) {
|
||||||
|
this.stmt.reset();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
all(...params: any[]): any[] {
|
all(...params: any[]): any[] {
|
||||||
|
try {
|
||||||
if (params.length > 0) {
|
if (params.length > 0) {
|
||||||
this.bindParams(params);
|
this.bindParams(params);
|
||||||
}
|
if (this.boundParams) {
|
||||||
|
|
||||||
this.stmt.bind(this.boundParams);
|
this.stmt.bind(this.boundParams);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const results: any[] = [];
|
const results: any[] = [];
|
||||||
while (this.stmt.step()) {
|
while (this.stmt.step()) {
|
||||||
@@ -422,6 +437,10 @@ class SQLJSStatement implements PreparedStatement {
|
|||||||
|
|
||||||
this.stmt.reset();
|
this.stmt.reset();
|
||||||
return results;
|
return results;
|
||||||
|
} catch (error) {
|
||||||
|
this.stmt.reset();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
iterate(...params: any[]): IterableIterator<any> {
|
iterate(...params: any[]): IterableIterator<any> {
|
||||||
@@ -455,12 +474,18 @@ class SQLJSStatement implements PreparedStatement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private bindParams(params: any[]): void {
|
private bindParams(params: any[]): void {
|
||||||
if (params.length === 1 && typeof params[0] === 'object' && !Array.isArray(params[0])) {
|
if (params.length === 0) {
|
||||||
|
this.boundParams = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.length === 1 && typeof params[0] === 'object' && !Array.isArray(params[0]) && params[0] !== null) {
|
||||||
// Named parameters passed as object
|
// Named parameters passed as object
|
||||||
this.boundParams = params[0];
|
this.boundParams = params[0];
|
||||||
} else {
|
} else {
|
||||||
// Positional parameters - sql.js uses array for positional
|
// Positional parameters - sql.js uses array for positional
|
||||||
this.boundParams = params;
|
// Filter out undefined values that might cause issues
|
||||||
|
this.boundParams = params.map(p => p === undefined ? null : p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ export class NodeRepository {
|
|||||||
node_type, package_name, display_name, description,
|
node_type, package_name, display_name, description,
|
||||||
category, development_style, is_ai_tool, is_trigger,
|
category, development_style, is_ai_tool, is_trigger,
|
||||||
is_webhook, is_versioned, version, documentation,
|
is_webhook, is_versioned, version, documentation,
|
||||||
properties_schema, operations, credentials_required
|
properties_schema, operations, credentials_required,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
outputs, output_names
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
stmt.run(
|
stmt.run(
|
||||||
@@ -41,7 +42,9 @@ export class NodeRepository {
|
|||||||
node.documentation || null,
|
node.documentation || null,
|
||||||
JSON.stringify(node.properties, null, 2),
|
JSON.stringify(node.properties, null, 2),
|
||||||
JSON.stringify(node.operations, null, 2),
|
JSON.stringify(node.operations, null, 2),
|
||||||
JSON.stringify(node.credentials, null, 2)
|
JSON.stringify(node.credentials, null, 2),
|
||||||
|
node.outputs ? JSON.stringify(node.outputs, null, 2) : null,
|
||||||
|
node.outputNames ? JSON.stringify(node.outputNames, null, 2) : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +73,9 @@ export class NodeRepository {
|
|||||||
properties: this.safeJsonParse(row.properties_schema, []),
|
properties: this.safeJsonParse(row.properties_schema, []),
|
||||||
operations: this.safeJsonParse(row.operations, []),
|
operations: this.safeJsonParse(row.operations, []),
|
||||||
credentials: this.safeJsonParse(row.credentials_required, []),
|
credentials: this.safeJsonParse(row.credentials_required, []),
|
||||||
hasDocumentation: !!row.documentation
|
hasDocumentation: !!row.documentation,
|
||||||
|
outputs: row.outputs ? this.safeJsonParse(row.outputs, null) : null,
|
||||||
|
outputNames: row.output_names ? this.safeJsonParse(row.output_names, null) : null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +243,9 @@ export class NodeRepository {
|
|||||||
properties: this.safeJsonParse(row.properties_schema, []),
|
properties: this.safeJsonParse(row.properties_schema, []),
|
||||||
operations: this.safeJsonParse(row.operations, []),
|
operations: this.safeJsonParse(row.operations, []),
|
||||||
credentials: this.safeJsonParse(row.credentials_required, []),
|
credentials: this.safeJsonParse(row.credentials_required, []),
|
||||||
hasDocumentation: !!row.documentation
|
hasDocumentation: !!row.documentation,
|
||||||
|
outputs: row.outputs ? this.safeJsonParse(row.outputs, null) : null,
|
||||||
|
outputNames: row.output_names ? this.safeJsonParse(row.output_names, null) : null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,6 +15,8 @@ CREATE TABLE IF NOT EXISTS nodes (
|
|||||||
properties_schema TEXT,
|
properties_schema TEXT,
|
||||||
operations TEXT,
|
operations TEXT,
|
||||||
credentials_required TEXT,
|
credentials_required TEXT,
|
||||||
|
outputs TEXT, -- JSON array of output definitions
|
||||||
|
output_names TEXT, -- JSON array of output names
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ export class SingleSessionHTTPServer {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up cleanup handler
|
// Set up cleanup handlers
|
||||||
transport.onclose = () => {
|
transport.onclose = () => {
|
||||||
const sid = transport.sessionId;
|
const sid = transport.sessionId;
|
||||||
if (sid) {
|
if (sid) {
|
||||||
@@ -378,6 +378,17 @@ export class SingleSessionHTTPServer {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle transport errors to prevent connection drops
|
||||||
|
transport.onerror = (error: Error) => {
|
||||||
|
const sid = transport.sessionId;
|
||||||
|
logger.error('Transport error', { sessionId: sid, error: error.message });
|
||||||
|
if (sid) {
|
||||||
|
this.removeSession(sid, 'transport_error').catch(err => {
|
||||||
|
logger.error('Error during transport error cleanup', { error: err });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Connect the server to the transport BEFORE handling the request
|
// Connect the server to the transport BEFORE handling the request
|
||||||
logger.info('handleRequest: Connecting server to new transport');
|
logger.info('handleRequest: Connecting server to new transport');
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
@@ -873,7 +884,7 @@ export class SingleSessionHTTPServer {
|
|||||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||||
// Only add event listener if the request object supports it (not in test mocks)
|
// Only add event listener if the request object supports it (not in test mocks)
|
||||||
if (typeof req.on === 'function') {
|
if (typeof req.on === 'function') {
|
||||||
req.on('close', () => {
|
const closeHandler = () => {
|
||||||
if (!res.headersSent && sessionId) {
|
if (!res.headersSent && sessionId) {
|
||||||
logger.info('Connection closed before response sent', { sessionId });
|
logger.info('Connection closed before response sent', { sessionId });
|
||||||
// Schedule immediate cleanup if connection closes unexpectedly
|
// Schedule immediate cleanup if connection closes unexpectedly
|
||||||
@@ -883,11 +894,20 @@ export class SingleSessionHTTPServer {
|
|||||||
const timeSinceAccess = Date.now() - metadata.lastAccess.getTime();
|
const timeSinceAccess = Date.now() - metadata.lastAccess.getTime();
|
||||||
// Only remove if it's been inactive for a bit to avoid race conditions
|
// Only remove if it's been inactive for a bit to avoid race conditions
|
||||||
if (timeSinceAccess > 60000) { // 1 minute
|
if (timeSinceAccess > 60000) { // 1 minute
|
||||||
this.removeSession(sessionId, 'connection_closed');
|
this.removeSession(sessionId, 'connection_closed').catch(err => {
|
||||||
|
logger.error('Error during connection close cleanup', { error: err });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
req.on('close', closeHandler);
|
||||||
|
|
||||||
|
// Clean up event listener when response ends to prevent memory leaks
|
||||||
|
res.on('finish', () => {
|
||||||
|
req.removeListener('close', closeHandler);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,8 +50,12 @@ export class DocsMapper {
|
|||||||
for (const relativePath of possiblePaths) {
|
for (const relativePath of possiblePaths) {
|
||||||
try {
|
try {
|
||||||
const fullPath = path.join(this.docsPath, relativePath);
|
const fullPath = path.join(this.docsPath, relativePath);
|
||||||
const content = await fs.readFile(fullPath, 'utf-8');
|
let content = await fs.readFile(fullPath, 'utf-8');
|
||||||
console.log(` ✓ Found docs at: ${relativePath}`);
|
console.log(` ✓ Found docs at: ${relativePath}`);
|
||||||
|
|
||||||
|
// Inject special guidance for loop nodes
|
||||||
|
content = this.enhanceLoopNodeDocumentation(nodeType, content);
|
||||||
|
|
||||||
return content;
|
return content;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// File doesn't exist, try next
|
// File doesn't exist, try next
|
||||||
@@ -62,4 +66,56 @@ export class DocsMapper {
|
|||||||
console.log(` ✗ No docs found for ${nodeName}`);
|
console.log(` ✗ No docs found for ${nodeName}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enhanceLoopNodeDocumentation(nodeType: string, content: string): string {
|
||||||
|
// Add critical output index information for SplitInBatches
|
||||||
|
if (nodeType.includes('splitInBatches')) {
|
||||||
|
const outputGuidance = `
|
||||||
|
|
||||||
|
## CRITICAL OUTPUT CONNECTION INFORMATION
|
||||||
|
|
||||||
|
**⚠️ OUTPUT INDICES ARE COUNTERINTUITIVE ⚠️**
|
||||||
|
|
||||||
|
The SplitInBatches node has TWO outputs with specific indices:
|
||||||
|
- **Output 0 (index 0) = "done"**: Receives final processed data when loop completes
|
||||||
|
- **Output 1 (index 1) = "loop"**: Receives current batch data during iteration
|
||||||
|
|
||||||
|
### Correct Connection Pattern:
|
||||||
|
1. Connect nodes that PROCESS items inside the loop to **Output 1 ("loop")**
|
||||||
|
2. Connect nodes that run AFTER the loop completes to **Output 0 ("done")**
|
||||||
|
3. The last processing node in the loop must connect back to the SplitInBatches node
|
||||||
|
|
||||||
|
### Common Mistake:
|
||||||
|
AI assistants often connect these backwards because the logical flow (loop first, then done) doesn't match the technical indices (done=0, loop=1).
|
||||||
|
|
||||||
|
`;
|
||||||
|
// Insert after the main description
|
||||||
|
const insertPoint = content.indexOf('## When to use');
|
||||||
|
if (insertPoint > -1) {
|
||||||
|
content = content.slice(0, insertPoint) + outputGuidance + content.slice(insertPoint);
|
||||||
|
} else {
|
||||||
|
// Append if no good insertion point found
|
||||||
|
content = outputGuidance + '\n' + content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add guidance for IF node
|
||||||
|
if (nodeType.includes('.if')) {
|
||||||
|
const outputGuidance = `
|
||||||
|
|
||||||
|
## Output Connection Information
|
||||||
|
|
||||||
|
The IF node has TWO outputs:
|
||||||
|
- **Output 0 (index 0) = "true"**: Items that match the condition
|
||||||
|
- **Output 1 (index 1) = "false"**: Items that do not match the condition
|
||||||
|
|
||||||
|
`;
|
||||||
|
const insertPoint = content.indexOf('## Node parameters');
|
||||||
|
if (insertPoint > -1) {
|
||||||
|
content = content.slice(0, insertPoint) + outputGuidance + content.slice(insertPoint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -282,6 +282,7 @@ export async function handleGetWorkflowStructure(args: unknown): Promise<McpTool
|
|||||||
id: workflow.id,
|
id: workflow.id,
|
||||||
name: workflow.name,
|
name: workflow.name,
|
||||||
active: workflow.active,
|
active: workflow.active,
|
||||||
|
isArchived: workflow.isArchived,
|
||||||
nodes: simplifiedNodes,
|
nodes: simplifiedNodes,
|
||||||
connections: workflow.connections,
|
connections: workflow.connections,
|
||||||
nodeCount: workflow.nodes.length,
|
nodeCount: workflow.nodes.length,
|
||||||
@@ -325,6 +326,7 @@ export async function handleGetWorkflowMinimal(args: unknown): Promise<McpToolRe
|
|||||||
id: workflow.id,
|
id: workflow.id,
|
||||||
name: workflow.name,
|
name: workflow.name,
|
||||||
active: workflow.active,
|
active: workflow.active,
|
||||||
|
isArchived: workflow.isArchived,
|
||||||
tags: workflow.tags || [],
|
tags: workflow.tags || [],
|
||||||
createdAt: workflow.createdAt,
|
createdAt: workflow.createdAt,
|
||||||
updatedAt: workflow.updatedAt
|
updatedAt: workflow.updatedAt
|
||||||
@@ -470,6 +472,7 @@ export async function handleListWorkflows(args: unknown): Promise<McpToolRespons
|
|||||||
id: workflow.id,
|
id: workflow.id,
|
||||||
name: workflow.name,
|
name: workflow.name,
|
||||||
active: workflow.active,
|
active: workflow.active,
|
||||||
|
isArchived: workflow.isArchived,
|
||||||
createdAt: workflow.createdAt,
|
createdAt: workflow.createdAt,
|
||||||
updatedAt: workflow.updatedAt,
|
updatedAt: workflow.updatedAt,
|
||||||
tags: workflow.tags || [],
|
tags: workflow.tags || [],
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { handleUpdatePartialWorkflow } from './handlers-workflow-diff';
|
|||||||
import { getToolDocumentation, getToolsOverview } from './tools-documentation';
|
import { getToolDocumentation, getToolsOverview } from './tools-documentation';
|
||||||
import { PROJECT_VERSION } from '../utils/version';
|
import { PROJECT_VERSION } from '../utils/version';
|
||||||
import { normalizeNodeType, getNodeTypeAlternatives, getWorkflowNodeType } from '../utils/node-utils';
|
import { normalizeNodeType, getNodeTypeAlternatives, getWorkflowNodeType } from '../utils/node-utils';
|
||||||
|
import { ToolValidation, Validator, ValidationError } from '../utils/validation-schemas';
|
||||||
import {
|
import {
|
||||||
negotiateProtocolVersion,
|
negotiateProtocolVersion,
|
||||||
logProtocolNegotiation,
|
logProtocolNegotiation,
|
||||||
@@ -460,9 +461,77 @@ export class N8NDocumentationMCPServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate required parameters for tool execution
|
* Enhanced parameter validation using schemas
|
||||||
*/
|
*/
|
||||||
private validateToolParams(toolName: string, args: any, requiredParams: string[]): void {
|
private validateToolParams(toolName: string, args: any, legacyRequiredParams?: string[]): void {
|
||||||
|
try {
|
||||||
|
// If legacy required params are provided, use the new validation but fall back to basic if needed
|
||||||
|
let validationResult;
|
||||||
|
|
||||||
|
switch (toolName) {
|
||||||
|
case 'validate_node_operation':
|
||||||
|
validationResult = ToolValidation.validateNodeOperation(args);
|
||||||
|
break;
|
||||||
|
case 'validate_node_minimal':
|
||||||
|
validationResult = ToolValidation.validateNodeMinimal(args);
|
||||||
|
break;
|
||||||
|
case 'validate_workflow':
|
||||||
|
case 'validate_workflow_connections':
|
||||||
|
case 'validate_workflow_expressions':
|
||||||
|
validationResult = ToolValidation.validateWorkflow(args);
|
||||||
|
break;
|
||||||
|
case 'search_nodes':
|
||||||
|
validationResult = ToolValidation.validateSearchNodes(args);
|
||||||
|
break;
|
||||||
|
case 'list_node_templates':
|
||||||
|
validationResult = ToolValidation.validateListNodeTemplates(args);
|
||||||
|
break;
|
||||||
|
case 'n8n_create_workflow':
|
||||||
|
validationResult = ToolValidation.validateCreateWorkflow(args);
|
||||||
|
break;
|
||||||
|
case 'n8n_get_workflow':
|
||||||
|
case 'n8n_get_workflow_details':
|
||||||
|
case 'n8n_get_workflow_structure':
|
||||||
|
case 'n8n_get_workflow_minimal':
|
||||||
|
case 'n8n_update_full_workflow':
|
||||||
|
case 'n8n_delete_workflow':
|
||||||
|
case 'n8n_validate_workflow':
|
||||||
|
case 'n8n_get_execution':
|
||||||
|
case 'n8n_delete_execution':
|
||||||
|
validationResult = ToolValidation.validateWorkflowId(args);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// For tools not yet migrated to schema validation, use basic validation
|
||||||
|
return this.validateToolParamsBasic(toolName, args, legacyRequiredParams || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validationResult.valid) {
|
||||||
|
const errorMessage = Validator.formatErrors(validationResult, toolName);
|
||||||
|
logger.error(`Parameter validation failed for ${toolName}:`, errorMessage);
|
||||||
|
throw new ValidationError(errorMessage);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Handle validation errors properly
|
||||||
|
if (error instanceof ValidationError) {
|
||||||
|
throw error; // Re-throw validation errors as-is
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle unexpected errors from validation system
|
||||||
|
logger.error(`Validation system error for ${toolName}:`, error);
|
||||||
|
|
||||||
|
// Provide a user-friendly error message
|
||||||
|
const errorMessage = error instanceof Error
|
||||||
|
? `Internal validation error: ${error.message}`
|
||||||
|
: `Internal validation error while processing ${toolName}`;
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy parameter validation (fallback)
|
||||||
|
*/
|
||||||
|
private validateToolParamsBasic(toolName: string, args: any, requiredParams: string[]): void {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
|
|
||||||
for (const param of requiredParams) {
|
for (const param of requiredParams) {
|
||||||
@@ -619,12 +688,17 @@ export class N8NDocumentationMCPServer {
|
|||||||
fix: 'Provide config as an object with node properties'
|
fix: 'Provide config as an object with node properties'
|
||||||
}],
|
}],
|
||||||
warnings: [],
|
warnings: [],
|
||||||
suggestions: [],
|
suggestions: [
|
||||||
|
'🔧 RECOVERY: Invalid config detected. Fix with:',
|
||||||
|
' • Ensure config is an object: { "resource": "...", "operation": "..." }',
|
||||||
|
' • Use get_node_essentials to see required fields for this node type',
|
||||||
|
' • Check if the node type is correct before configuring it'
|
||||||
|
],
|
||||||
summary: {
|
summary: {
|
||||||
hasErrors: true,
|
hasErrors: true,
|
||||||
errorCount: 1,
|
errorCount: 1,
|
||||||
warningCount: 0,
|
warningCount: 0,
|
||||||
suggestionCount: 0
|
suggestionCount: 3
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -638,7 +712,10 @@ export class N8NDocumentationMCPServer {
|
|||||||
nodeType: args.nodeType || 'unknown',
|
nodeType: args.nodeType || 'unknown',
|
||||||
displayName: 'Unknown Node',
|
displayName: 'Unknown Node',
|
||||||
valid: false,
|
valid: false,
|
||||||
missingRequiredFields: ['Invalid config format - expected object']
|
missingRequiredFields: [
|
||||||
|
'Invalid config format - expected object',
|
||||||
|
'🔧 RECOVERY: Use format { "resource": "...", "operation": "..." } or {} for empty config'
|
||||||
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return this.validateNodeMinimal(args.nodeType, args.config);
|
return this.validateNodeMinimal(args.nodeType, args.config);
|
||||||
@@ -834,10 +911,26 @@ export class N8NDocumentationMCPServer {
|
|||||||
null
|
null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Process outputs to provide clear mapping
|
||||||
|
let outputs = undefined;
|
||||||
|
if (node.outputNames && node.outputNames.length > 0) {
|
||||||
|
outputs = node.outputNames.map((name: string, index: number) => {
|
||||||
|
// Special handling for loop nodes like SplitInBatches
|
||||||
|
const descriptions = this.getOutputDescriptions(node.nodeType, name, index);
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
name,
|
||||||
|
description: descriptions.description,
|
||||||
|
connectionGuidance: descriptions.connectionGuidance
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
workflowNodeType: getWorkflowNodeType(node.package, node.nodeType),
|
workflowNodeType: getWorkflowNodeType(node.package, node.nodeType),
|
||||||
aiToolCapabilities
|
aiToolCapabilities,
|
||||||
|
outputs
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1937,6 +2030,52 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getOutputDescriptions(nodeType: string, outputName: string, index: number): { description: string, connectionGuidance: string } {
|
||||||
|
// Special handling for loop nodes
|
||||||
|
if (nodeType === 'nodes-base.splitInBatches') {
|
||||||
|
if (outputName === 'done' && index === 0) {
|
||||||
|
return {
|
||||||
|
description: 'Final processed data after all iterations complete',
|
||||||
|
connectionGuidance: 'Connect to nodes that should run AFTER the loop completes'
|
||||||
|
};
|
||||||
|
} else if (outputName === 'loop' && index === 1) {
|
||||||
|
return {
|
||||||
|
description: 'Current batch data for this iteration',
|
||||||
|
connectionGuidance: 'Connect to nodes that process items INSIDE the loop (and connect their output back to this node)'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for IF node
|
||||||
|
if (nodeType === 'nodes-base.if') {
|
||||||
|
if (outputName === 'true' && index === 0) {
|
||||||
|
return {
|
||||||
|
description: 'Items that match the condition',
|
||||||
|
connectionGuidance: 'Connect to nodes that handle the TRUE case'
|
||||||
|
};
|
||||||
|
} else if (outputName === 'false' && index === 1) {
|
||||||
|
return {
|
||||||
|
description: 'Items that do not match the condition',
|
||||||
|
connectionGuidance: 'Connect to nodes that handle the FALSE case'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special handling for Switch node
|
||||||
|
if (nodeType === 'nodes-base.switch') {
|
||||||
|
return {
|
||||||
|
description: `Output ${index}: ${outputName || 'Route ' + index}`,
|
||||||
|
connectionGuidance: `Connect to nodes for the "${outputName || 'route ' + index}" case`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default handling
|
||||||
|
return {
|
||||||
|
description: outputName || `Output ${index}`,
|
||||||
|
connectionGuidance: `Connect to downstream nodes`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private getCommonAIToolUseCases(nodeType: string): string[] {
|
private getCommonAIToolUseCases(nodeType: string): string[] {
|
||||||
const useCaseMap: Record<string, string[]> = {
|
const useCaseMap: Record<string, string[]> = {
|
||||||
'nodes-base.slack': [
|
'nodes-base.slack': [
|
||||||
@@ -2079,12 +2218,12 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
|||||||
// Get properties
|
// Get properties
|
||||||
const properties = node.properties || [];
|
const properties = node.properties || [];
|
||||||
|
|
||||||
// Extract operation context
|
// Extract operation context (safely handle undefined config properties)
|
||||||
const operationContext = {
|
const operationContext = {
|
||||||
resource: config.resource,
|
resource: config?.resource,
|
||||||
operation: config.operation,
|
operation: config?.operation,
|
||||||
action: config.action,
|
action: config?.action,
|
||||||
mode: config.mode
|
mode: config?.mode
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find missing required fields
|
// Find missing required fields
|
||||||
@@ -2101,7 +2240,7 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
|||||||
// Check show conditions
|
// Check show conditions
|
||||||
if (prop.displayOptions.show) {
|
if (prop.displayOptions.show) {
|
||||||
for (const [key, values] of Object.entries(prop.displayOptions.show)) {
|
for (const [key, values] of Object.entries(prop.displayOptions.show)) {
|
||||||
const configValue = config[key];
|
const configValue = config?.[key];
|
||||||
const expectedValues = Array.isArray(values) ? values : [values];
|
const expectedValues = Array.isArray(values) ? values : [values];
|
||||||
|
|
||||||
if (!expectedValues.includes(configValue)) {
|
if (!expectedValues.includes(configValue)) {
|
||||||
@@ -2114,7 +2253,7 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
|||||||
// Check hide conditions
|
// Check hide conditions
|
||||||
if (isVisible && prop.displayOptions.hide) {
|
if (isVisible && prop.displayOptions.hide) {
|
||||||
for (const [key, values] of Object.entries(prop.displayOptions.hide)) {
|
for (const [key, values] of Object.entries(prop.displayOptions.hide)) {
|
||||||
const configValue = config[key];
|
const configValue = config?.[key];
|
||||||
const expectedValues = Array.isArray(values) ? values : [values];
|
const expectedValues = Array.isArray(values) ? values : [values];
|
||||||
|
|
||||||
if (expectedValues.includes(configValue)) {
|
if (expectedValues.includes(configValue)) {
|
||||||
@@ -2127,8 +2266,8 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
|||||||
if (!isVisible) continue;
|
if (!isVisible) continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if field is missing
|
// Check if field is missing (safely handle null/undefined config)
|
||||||
if (!(prop.name in config)) {
|
if (!config || !(prop.name in config)) {
|
||||||
missingFields.push(prop.displayName || prop.name);
|
missingFields.push(prop.displayName || prop.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2538,6 +2677,16 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
|||||||
async shutdown(): Promise<void> {
|
async shutdown(): Promise<void> {
|
||||||
logger.info('Shutting down MCP server...');
|
logger.info('Shutting down MCP server...');
|
||||||
|
|
||||||
|
// Clean up cache timers to prevent memory leaks
|
||||||
|
if (this.cache) {
|
||||||
|
try {
|
||||||
|
this.cache.destroy();
|
||||||
|
logger.info('Cache timers cleaned up');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error cleaning up cache:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Close database connection if it exists
|
// Close database connection if it exists
|
||||||
if (this.db) {
|
if (this.db) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -16,14 +16,19 @@ export interface ParsedNode {
|
|||||||
isVersioned: boolean;
|
isVersioned: boolean;
|
||||||
packageName: string;
|
packageName: string;
|
||||||
documentation?: string;
|
documentation?: string;
|
||||||
|
outputs?: any[];
|
||||||
|
outputNames?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NodeParser {
|
export class NodeParser {
|
||||||
private propertyExtractor = new PropertyExtractor();
|
private propertyExtractor = new PropertyExtractor();
|
||||||
|
private currentNodeClass: any = null;
|
||||||
|
|
||||||
parse(nodeClass: any, packageName: string): ParsedNode {
|
parse(nodeClass: any, packageName: string): ParsedNode {
|
||||||
|
this.currentNodeClass = nodeClass;
|
||||||
// Get base description (handles versioned nodes)
|
// Get base description (handles versioned nodes)
|
||||||
const description = this.getNodeDescription(nodeClass);
|
const description = this.getNodeDescription(nodeClass);
|
||||||
|
const outputInfo = this.extractOutputs(description);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
style: this.detectStyle(nodeClass),
|
style: this.detectStyle(nodeClass),
|
||||||
@@ -39,7 +44,9 @@ export class NodeParser {
|
|||||||
operations: this.propertyExtractor.extractOperations(nodeClass),
|
operations: this.propertyExtractor.extractOperations(nodeClass),
|
||||||
version: this.extractVersion(nodeClass),
|
version: this.extractVersion(nodeClass),
|
||||||
isVersioned: this.detectVersioned(nodeClass),
|
isVersioned: this.detectVersioned(nodeClass),
|
||||||
packageName: packageName
|
packageName: packageName,
|
||||||
|
outputs: outputInfo.outputs,
|
||||||
|
outputNames: outputInfo.outputNames
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,4 +229,51 @@ export class NodeParser {
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractOutputs(description: any): { outputs?: any[], outputNames?: string[] } {
|
||||||
|
const result: { outputs?: any[], outputNames?: string[] } = {};
|
||||||
|
|
||||||
|
// First check the base description
|
||||||
|
if (description.outputs) {
|
||||||
|
result.outputs = Array.isArray(description.outputs) ? description.outputs : [description.outputs];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description.outputNames) {
|
||||||
|
result.outputNames = Array.isArray(description.outputNames) ? description.outputNames : [description.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) {
|
||||||
|
// Get the latest version
|
||||||
|
const versions = Object.keys(instance.nodeVersions).map(Number);
|
||||||
|
const latestVersion = Math.max(...versions);
|
||||||
|
const versionedDescription = instance.nodeVersions[latestVersion]?.description;
|
||||||
|
|
||||||
|
if (versionedDescription) {
|
||||||
|
if (versionedDescription.outputs) {
|
||||||
|
result.outputs = Array.isArray(versionedDescription.outputs)
|
||||||
|
? versionedDescription.outputs
|
||||||
|
: [versionedDescription.outputs];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (versionedDescription.outputNames) {
|
||||||
|
result.outputNames = Array.isArray(versionedDescription.outputNames)
|
||||||
|
? versionedDescription.outputNames
|
||||||
|
: [versionedDescription.outputNames];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors from instantiating node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { createDatabaseAdapter } from '../database/database-adapter';
|
import { createDatabaseAdapter } from '../database/database-adapter';
|
||||||
import { N8nNodeLoader } from '../loaders/node-loader';
|
import { N8nNodeLoader } from '../loaders/node-loader';
|
||||||
import { NodeParser } from '../parsers/node-parser';
|
import { NodeParser, ParsedNode } from '../parsers/node-parser';
|
||||||
import { DocsMapper } from '../mappers/docs-mapper';
|
import { DocsMapper } from '../mappers/docs-mapper';
|
||||||
import { NodeRepository } from '../database/node-repository';
|
import { NodeRepository } from '../database/node-repository';
|
||||||
import { TemplateSanitizer } from '../utils/template-sanitizer';
|
import { TemplateSanitizer } from '../utils/template-sanitizer';
|
||||||
@@ -46,7 +46,10 @@ async function rebuild() {
|
|||||||
withDocs: 0
|
withDocs: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
// Process each node
|
// Process each node (documentation fetching must be outside transaction due to async)
|
||||||
|
console.log('🔄 Processing nodes...');
|
||||||
|
const processedNodes: Array<{ parsed: ParsedNode; docs: string | undefined; nodeName: string }> = [];
|
||||||
|
|
||||||
for (const { packageName, nodeName, NodeClass } of nodes) {
|
for (const { packageName, nodeName, NodeClass } of nodes) {
|
||||||
try {
|
try {
|
||||||
// Parse node
|
// Parse node
|
||||||
@@ -54,15 +57,34 @@ async function rebuild() {
|
|||||||
|
|
||||||
// Validate parsed data
|
// Validate parsed data
|
||||||
if (!parsed.nodeType || !parsed.displayName) {
|
if (!parsed.nodeType || !parsed.displayName) {
|
||||||
throw new Error('Missing required fields');
|
throw new Error(`Missing required fields - nodeType: ${parsed.nodeType}, displayName: ${parsed.displayName}, packageName: ${parsed.packageName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional validation for required fields
|
||||||
|
if (!parsed.packageName) {
|
||||||
|
throw new Error(`Missing packageName for node ${nodeName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get documentation
|
// Get documentation
|
||||||
const docs = await mapper.fetchDocumentation(parsed.nodeType);
|
const docs = await mapper.fetchDocumentation(parsed.nodeType);
|
||||||
parsed.documentation = docs || undefined;
|
parsed.documentation = docs || undefined;
|
||||||
|
|
||||||
// Save to database
|
processedNodes.push({ parsed, docs: docs || undefined, nodeName });
|
||||||
|
} catch (error) {
|
||||||
|
stats.failed++;
|
||||||
|
const errorMessage = (error as Error).message;
|
||||||
|
console.error(`❌ Failed to process ${nodeName}: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now save all processed nodes to database
|
||||||
|
console.log(`\n💾 Saving ${processedNodes.length} processed nodes to database...`);
|
||||||
|
|
||||||
|
let saved = 0;
|
||||||
|
for (const { parsed, docs, nodeName } of processedNodes) {
|
||||||
|
try {
|
||||||
repository.saveNode(parsed);
|
repository.saveNode(parsed);
|
||||||
|
saved++;
|
||||||
|
|
||||||
// Update statistics
|
// Update statistics
|
||||||
stats.successful++;
|
stats.successful++;
|
||||||
@@ -76,14 +98,29 @@ async function rebuild() {
|
|||||||
console.log(`✅ ${parsed.nodeType} [Props: ${parsed.properties.length}, Ops: ${parsed.operations.length}]`);
|
console.log(`✅ ${parsed.nodeType} [Props: ${parsed.properties.length}, Ops: ${parsed.operations.length}]`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
stats.failed++;
|
stats.failed++;
|
||||||
console.error(`❌ Failed to process ${nodeName}: ${(error as Error).message}`);
|
const errorMessage = (error as Error).message;
|
||||||
|
console.error(`❌ Failed to save ${nodeName}: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`💾 Save completed: ${saved} nodes saved successfully`);
|
||||||
|
|
||||||
// Validation check
|
// Validation check
|
||||||
console.log('\n🔍 Running validation checks...');
|
console.log('\n🔍 Running validation checks...');
|
||||||
|
try {
|
||||||
const validationResults = validateDatabase(repository);
|
const validationResults = validateDatabase(repository);
|
||||||
|
|
||||||
|
if (!validationResults.passed) {
|
||||||
|
console.log('⚠️ Validation Issues:');
|
||||||
|
validationResults.issues.forEach(issue => console.log(` - ${issue}`));
|
||||||
|
} else {
|
||||||
|
console.log('✅ All validation checks passed');
|
||||||
|
}
|
||||||
|
} catch (validationError) {
|
||||||
|
console.error('❌ Validation failed:', (validationError as Error).message);
|
||||||
|
console.log('⚠️ Skipping validation due to database compatibility issues');
|
||||||
|
}
|
||||||
|
|
||||||
// Summary
|
// Summary
|
||||||
console.log('\n📊 Summary:');
|
console.log('\n📊 Summary:');
|
||||||
console.log(` Total nodes: ${nodes.length}`);
|
console.log(` Total nodes: ${nodes.length}`);
|
||||||
@@ -96,11 +133,6 @@ async function rebuild() {
|
|||||||
console.log(` With Operations: ${stats.withOperations}`);
|
console.log(` With Operations: ${stats.withOperations}`);
|
||||||
console.log(` With Documentation: ${stats.withDocs}`);
|
console.log(` With Documentation: ${stats.withDocs}`);
|
||||||
|
|
||||||
if (!validationResults.passed) {
|
|
||||||
console.log('\n⚠️ Validation Issues:');
|
|
||||||
validationResults.issues.forEach(issue => console.log(` - ${issue}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize templates if they exist
|
// Sanitize templates if they exist
|
||||||
console.log('\n🧹 Checking for templates to sanitize...');
|
console.log('\n🧹 Checking for templates to sanitize...');
|
||||||
const templateCount = db.prepare('SELECT COUNT(*) as count FROM templates').get() as { count: number };
|
const templateCount = db.prepare('SELECT COUNT(*) as count FROM templates').get() as { count: number };
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { ConfigValidator, ValidationResult, ValidationError, ValidationWarning } from './config-validator';
|
import { ConfigValidator, ValidationResult, ValidationError, ValidationWarning } from './config-validator';
|
||||||
import { NodeSpecificValidators, NodeValidationContext } from './node-specific-validators';
|
import { NodeSpecificValidators, NodeValidationContext } from './node-specific-validators';
|
||||||
|
import { FixedCollectionValidator } from '../utils/fixed-collection-validator';
|
||||||
|
|
||||||
export type ValidationMode = 'full' | 'operation' | 'minimal';
|
export type ValidationMode = 'full' | 'operation' | 'minimal';
|
||||||
export type ValidationProfile = 'strict' | 'runtime' | 'ai-friendly' | 'minimal';
|
export type ValidationProfile = 'strict' | 'runtime' | 'ai-friendly' | 'minimal';
|
||||||
@@ -44,6 +45,19 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
|||||||
mode: ValidationMode = 'operation',
|
mode: ValidationMode = 'operation',
|
||||||
profile: ValidationProfile = 'ai-friendly'
|
profile: ValidationProfile = 'ai-friendly'
|
||||||
): EnhancedValidationResult {
|
): EnhancedValidationResult {
|
||||||
|
// Input validation - ensure parameters are valid
|
||||||
|
if (typeof nodeType !== 'string') {
|
||||||
|
throw new Error(`Invalid nodeType: expected string, got ${typeof nodeType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config || typeof config !== 'object') {
|
||||||
|
throw new Error(`Invalid config: expected object, got ${typeof config}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(properties)) {
|
||||||
|
throw new Error(`Invalid properties: expected array, got ${typeof properties}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Extract operation context from config
|
// Extract operation context from config
|
||||||
const operationContext = this.extractOperationContext(config);
|
const operationContext = this.extractOperationContext(config);
|
||||||
|
|
||||||
@@ -86,6 +100,9 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
|||||||
// Generate next steps based on errors
|
// Generate next steps based on errors
|
||||||
enhancedResult.nextSteps = this.generateNextSteps(enhancedResult);
|
enhancedResult.nextSteps = this.generateNextSteps(enhancedResult);
|
||||||
|
|
||||||
|
// Recalculate validity after all enhancements (crucial for fixedCollection validation)
|
||||||
|
enhancedResult.valid = enhancedResult.errors.length === 0;
|
||||||
|
|
||||||
return enhancedResult;
|
return enhancedResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +203,20 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
|||||||
config: Record<string, any>,
|
config: Record<string, any>,
|
||||||
result: EnhancedValidationResult
|
result: EnhancedValidationResult
|
||||||
): void {
|
): void {
|
||||||
|
// Type safety check - this should never happen with proper validation
|
||||||
|
if (typeof nodeType !== 'string') {
|
||||||
|
result.errors.push({
|
||||||
|
type: 'invalid_type',
|
||||||
|
property: 'nodeType',
|
||||||
|
message: `Invalid nodeType: expected string, got ${typeof nodeType}`,
|
||||||
|
fix: 'Provide a valid node type string (e.g., "nodes-base.webhook")'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, validate fixedCollection properties for known problematic nodes
|
||||||
|
this.validateFixedCollectionStructures(nodeType, config, result);
|
||||||
|
|
||||||
// Create context for node-specific validators
|
// Create context for node-specific validators
|
||||||
const context: NodeValidationContext = {
|
const context: NodeValidationContext = {
|
||||||
config,
|
config,
|
||||||
@@ -195,8 +226,11 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
|||||||
autofix: result.autofix || {}
|
autofix: result.autofix || {}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Normalize node type (handle both 'n8n-nodes-base.x' and 'nodes-base.x' formats)
|
||||||
|
const normalizedNodeType = nodeType.replace('n8n-nodes-base.', 'nodes-base.');
|
||||||
|
|
||||||
// Use node-specific validators
|
// Use node-specific validators
|
||||||
switch (nodeType) {
|
switch (normalizedNodeType) {
|
||||||
case 'nodes-base.slack':
|
case 'nodes-base.slack':
|
||||||
NodeSpecificValidators.validateSlack(context);
|
NodeSpecificValidators.validateSlack(context);
|
||||||
this.enhanceSlackValidation(config, result);
|
this.enhanceSlackValidation(config, result);
|
||||||
@@ -235,6 +269,21 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
|||||||
case 'nodes-base.mysql':
|
case 'nodes-base.mysql':
|
||||||
NodeSpecificValidators.validateMySQL(context);
|
NodeSpecificValidators.validateMySQL(context);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'nodes-base.switch':
|
||||||
|
this.validateSwitchNodeStructure(config, result);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'nodes-base.if':
|
||||||
|
this.validateIfNodeStructure(config, result);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'nodes-base.filter':
|
||||||
|
this.validateFilterNodeStructure(config, result);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Additional nodes handled by FixedCollectionValidator
|
||||||
|
// No need for specific validators as the generic utility handles them
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update autofix if changes were made
|
// Update autofix if changes were made
|
||||||
@@ -468,4 +517,129 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate fixedCollection structures for known problematic nodes
|
||||||
|
* This prevents the "propertyValues[itemName] is not iterable" error
|
||||||
|
*/
|
||||||
|
private static validateFixedCollectionStructures(
|
||||||
|
nodeType: string,
|
||||||
|
config: Record<string, any>,
|
||||||
|
result: EnhancedValidationResult
|
||||||
|
): void {
|
||||||
|
// Use the generic FixedCollectionValidator
|
||||||
|
const validationResult = FixedCollectionValidator.validate(nodeType, config);
|
||||||
|
|
||||||
|
if (!validationResult.isValid) {
|
||||||
|
// Add errors to the result
|
||||||
|
for (const error of validationResult.errors) {
|
||||||
|
result.errors.push({
|
||||||
|
type: 'invalid_value',
|
||||||
|
property: error.pattern.split('.')[0], // Get the root property
|
||||||
|
message: error.message,
|
||||||
|
fix: error.fix
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply autofix if available
|
||||||
|
if (validationResult.autofix) {
|
||||||
|
// For nodes like If/Filter where the entire config might be replaced,
|
||||||
|
// we need to handle it specially
|
||||||
|
if (typeof validationResult.autofix === 'object' && !Array.isArray(validationResult.autofix)) {
|
||||||
|
result.autofix = {
|
||||||
|
...result.autofix,
|
||||||
|
...validationResult.autofix
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// If the autofix is an array (like for If/Filter nodes), wrap it properly
|
||||||
|
const firstError = validationResult.errors[0];
|
||||||
|
if (firstError) {
|
||||||
|
const rootProperty = firstError.pattern.split('.')[0];
|
||||||
|
result.autofix = {
|
||||||
|
...result.autofix,
|
||||||
|
[rootProperty]: validationResult.autofix
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate Switch node structure specifically
|
||||||
|
*/
|
||||||
|
private static validateSwitchNodeStructure(
|
||||||
|
config: Record<string, any>,
|
||||||
|
result: EnhancedValidationResult
|
||||||
|
): void {
|
||||||
|
if (!config.rules) return;
|
||||||
|
|
||||||
|
// Skip if already caught by validateFixedCollectionStructures
|
||||||
|
const hasFixedCollectionError = result.errors.some(e =>
|
||||||
|
e.property === 'rules' && e.message.includes('propertyValues[itemName] is not iterable')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasFixedCollectionError) return;
|
||||||
|
|
||||||
|
// Validate rules.values structure if present
|
||||||
|
if (config.rules.values && Array.isArray(config.rules.values)) {
|
||||||
|
config.rules.values.forEach((rule: any, index: number) => {
|
||||||
|
if (!rule.conditions) {
|
||||||
|
result.warnings.push({
|
||||||
|
type: 'missing_common',
|
||||||
|
property: 'rules',
|
||||||
|
message: `Switch rule ${index + 1} is missing "conditions" property`,
|
||||||
|
suggestion: 'Each rule in the values array should have a "conditions" property'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!rule.outputKey && rule.renameOutput !== false) {
|
||||||
|
result.warnings.push({
|
||||||
|
type: 'missing_common',
|
||||||
|
property: 'rules',
|
||||||
|
message: `Switch rule ${index + 1} is missing "outputKey" property`,
|
||||||
|
suggestion: 'Add "outputKey" to specify which output to use when this rule matches'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate If node structure specifically
|
||||||
|
*/
|
||||||
|
private static validateIfNodeStructure(
|
||||||
|
config: Record<string, any>,
|
||||||
|
result: EnhancedValidationResult
|
||||||
|
): void {
|
||||||
|
if (!config.conditions) return;
|
||||||
|
|
||||||
|
// Skip if already caught by validateFixedCollectionStructures
|
||||||
|
const hasFixedCollectionError = result.errors.some(e =>
|
||||||
|
e.property === 'conditions' && e.message.includes('propertyValues[itemName] is not iterable')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasFixedCollectionError) return;
|
||||||
|
|
||||||
|
// Add any If-node-specific validation here in the future
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate Filter node structure specifically
|
||||||
|
*/
|
||||||
|
private static validateFilterNodeStructure(
|
||||||
|
config: Record<string, any>,
|
||||||
|
result: EnhancedValidationResult
|
||||||
|
): void {
|
||||||
|
if (!config.conditions) return;
|
||||||
|
|
||||||
|
// Skip if already caught by validateFixedCollectionStructures
|
||||||
|
const hasFixedCollectionError = result.errors.some(e =>
|
||||||
|
e.property === 'conditions' && e.message.includes('propertyValues[itemName] is not iterable')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasFixedCollectionError) return;
|
||||||
|
|
||||||
|
// Add any Filter-node-specific validation here in the future
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -72,11 +72,25 @@ export interface WorkflowValidationResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class WorkflowValidator {
|
export class WorkflowValidator {
|
||||||
|
private currentWorkflow: WorkflowJson | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private nodeRepository: NodeRepository,
|
private nodeRepository: NodeRepository,
|
||||||
private nodeValidator: typeof EnhancedConfigValidator
|
private nodeValidator: typeof EnhancedConfigValidator
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a node is a Sticky Note or other non-executable node
|
||||||
|
*/
|
||||||
|
private isStickyNote(node: WorkflowNode): boolean {
|
||||||
|
const stickyNoteTypes = [
|
||||||
|
'n8n-nodes-base.stickyNote',
|
||||||
|
'nodes-base.stickyNote',
|
||||||
|
'@n8n/n8n-nodes-base.stickyNote'
|
||||||
|
];
|
||||||
|
return stickyNoteTypes.includes(node.type);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a complete workflow
|
* Validate a complete workflow
|
||||||
*/
|
*/
|
||||||
@@ -89,6 +103,9 @@ export class WorkflowValidator {
|
|||||||
profile?: 'minimal' | 'runtime' | 'ai-friendly' | 'strict';
|
profile?: 'minimal' | 'runtime' | 'ai-friendly' | 'strict';
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<WorkflowValidationResult> {
|
): Promise<WorkflowValidationResult> {
|
||||||
|
// Store current workflow for access in helper methods
|
||||||
|
this.currentWorkflow = workflow;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
validateNodes = true,
|
validateNodes = true,
|
||||||
validateConnections = true,
|
validateConnections = true,
|
||||||
@@ -122,9 +139,10 @@ export class WorkflowValidator {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update statistics after null check
|
// Update statistics after null check (exclude sticky notes from counts)
|
||||||
result.statistics.totalNodes = Array.isArray(workflow.nodes) ? workflow.nodes.length : 0;
|
const executableNodes = Array.isArray(workflow.nodes) ? workflow.nodes.filter(n => !this.isStickyNote(n)) : [];
|
||||||
result.statistics.enabledNodes = Array.isArray(workflow.nodes) ? workflow.nodes.filter(n => !n.disabled).length : 0;
|
result.statistics.totalNodes = executableNodes.length;
|
||||||
|
result.statistics.enabledNodes = executableNodes.filter(n => !n.disabled).length;
|
||||||
|
|
||||||
// Basic workflow structure validation
|
// Basic workflow structure validation
|
||||||
this.validateWorkflowStructure(workflow, result);
|
this.validateWorkflowStructure(workflow, result);
|
||||||
@@ -138,21 +156,26 @@ export class WorkflowValidator {
|
|||||||
|
|
||||||
// Validate connections if requested
|
// Validate connections if requested
|
||||||
if (validateConnections) {
|
if (validateConnections) {
|
||||||
this.validateConnections(workflow, result);
|
this.validateConnections(workflow, result, profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate expressions if requested
|
// Validate expressions if requested
|
||||||
if (validateExpressions && workflow.nodes.length > 0) {
|
if (validateExpressions && workflow.nodes.length > 0) {
|
||||||
this.validateExpressions(workflow, result);
|
this.validateExpressions(workflow, result, profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check workflow patterns and best practices
|
// Check workflow patterns and best practices
|
||||||
if (workflow.nodes.length > 0) {
|
if (workflow.nodes.length > 0) {
|
||||||
this.checkWorkflowPatterns(workflow, result);
|
this.checkWorkflowPatterns(workflow, result, profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add suggestions based on findings
|
// Add suggestions based on findings
|
||||||
this.generateSuggestions(workflow, result);
|
this.generateSuggestions(workflow, result);
|
||||||
|
|
||||||
|
// Add AI-specific recovery suggestions if there are errors
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
this.addErrorRecoverySuggestions(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -303,7 +326,7 @@ export class WorkflowValidator {
|
|||||||
profile: string
|
profile: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const node of workflow.nodes) {
|
for (const node of workflow.nodes) {
|
||||||
if (node.disabled) continue;
|
if (node.disabled || this.isStickyNote(node)) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate node name length
|
// Validate node name length
|
||||||
@@ -495,7 +518,8 @@ export class WorkflowValidator {
|
|||||||
*/
|
*/
|
||||||
private validateConnections(
|
private validateConnections(
|
||||||
workflow: WorkflowJson,
|
workflow: WorkflowJson,
|
||||||
result: WorkflowValidationResult
|
result: WorkflowValidationResult,
|
||||||
|
profile: string = 'runtime'
|
||||||
): void {
|
): void {
|
||||||
const nodeMap = new Map(workflow.nodes.map(n => [n.name, n]));
|
const nodeMap = new Map(workflow.nodes.map(n => [n.name, n]));
|
||||||
const nodeIdMap = new Map(workflow.nodes.map(n => [n.id, n]));
|
const nodeIdMap = new Map(workflow.nodes.map(n => [n.id, n]));
|
||||||
@@ -586,9 +610,9 @@ export class WorkflowValidator {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for orphaned nodes
|
// Check for orphaned nodes (exclude sticky notes)
|
||||||
for (const node of workflow.nodes) {
|
for (const node of workflow.nodes) {
|
||||||
if (node.disabled) continue;
|
if (node.disabled || this.isStickyNote(node)) continue;
|
||||||
|
|
||||||
const normalizedType = node.type.replace('n8n-nodes-base.', 'nodes-base.');
|
const normalizedType = node.type.replace('n8n-nodes-base.', 'nodes-base.');
|
||||||
const isTrigger = normalizedType.toLowerCase().includes('trigger') ||
|
const isTrigger = normalizedType.toLowerCase().includes('trigger') ||
|
||||||
@@ -607,8 +631,8 @@ export class WorkflowValidator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for cycles
|
// Check for cycles (skip in minimal profile to reduce false positives)
|
||||||
if (this.hasCycle(workflow)) {
|
if (profile !== 'minimal' && this.hasCycle(workflow)) {
|
||||||
result.errors.push({
|
result.errors.push({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: 'Workflow contains a cycle (infinite loop)'
|
message: 'Workflow contains a cycle (infinite loop)'
|
||||||
@@ -627,6 +651,9 @@ export class WorkflowValidator {
|
|||||||
result: WorkflowValidationResult,
|
result: WorkflowValidationResult,
|
||||||
outputType: 'main' | 'error' | 'ai_tool'
|
outputType: 'main' | 'error' | 'ai_tool'
|
||||||
): void {
|
): void {
|
||||||
|
// Get source node for special validation
|
||||||
|
const sourceNode = nodeMap.get(sourceName);
|
||||||
|
|
||||||
outputs.forEach((outputConnections, outputIndex) => {
|
outputs.forEach((outputConnections, outputIndex) => {
|
||||||
if (!outputConnections) return;
|
if (!outputConnections) return;
|
||||||
|
|
||||||
@@ -641,13 +668,27 @@ export class WorkflowValidator {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Special validation for SplitInBatches node
|
||||||
|
if (sourceNode && sourceNode.type === 'n8n-nodes-base.splitInBatches') {
|
||||||
|
this.validateSplitInBatchesConnection(
|
||||||
|
sourceNode,
|
||||||
|
outputIndex,
|
||||||
|
connection,
|
||||||
|
nodeMap,
|
||||||
|
result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check for self-referencing connections
|
// Check for self-referencing connections
|
||||||
if (connection.node === sourceName) {
|
if (connection.node === sourceName) {
|
||||||
|
// This is only a warning for non-loop nodes
|
||||||
|
if (sourceNode && sourceNode.type !== 'n8n-nodes-base.splitInBatches') {
|
||||||
result.warnings.push({
|
result.warnings.push({
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
message: `Node "${sourceName}" has a self-referencing connection. This can cause infinite loops.`
|
message: `Node "${sourceName}" has a self-referencing connection. This can cause infinite loops.`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const targetNode = nodeMap.get(connection.node);
|
const targetNode = nodeMap.get(connection.node);
|
||||||
|
|
||||||
@@ -728,12 +769,31 @@ export class WorkflowValidator {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if workflow has cycles
|
* Check if workflow has cycles
|
||||||
|
* Allow legitimate loops for SplitInBatches and similar loop nodes
|
||||||
*/
|
*/
|
||||||
private hasCycle(workflow: WorkflowJson): boolean {
|
private hasCycle(workflow: WorkflowJson): boolean {
|
||||||
const visited = new Set<string>();
|
const visited = new Set<string>();
|
||||||
const recursionStack = new Set<string>();
|
const recursionStack = new Set<string>();
|
||||||
|
const nodeTypeMap = new Map<string, string>();
|
||||||
|
|
||||||
const hasCycleDFS = (nodeName: string): boolean => {
|
// Build node type map (exclude sticky notes)
|
||||||
|
workflow.nodes.forEach(node => {
|
||||||
|
if (!this.isStickyNote(node)) {
|
||||||
|
nodeTypeMap.set(node.name, node.type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Known legitimate loop node types
|
||||||
|
const loopNodeTypes = [
|
||||||
|
'n8n-nodes-base.splitInBatches',
|
||||||
|
'nodes-base.splitInBatches',
|
||||||
|
'n8n-nodes-base.itemLists',
|
||||||
|
'nodes-base.itemLists',
|
||||||
|
'n8n-nodes-base.loop',
|
||||||
|
'nodes-base.loop'
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasCycleDFS = (nodeName: string, pathFromLoopNode: boolean = false): boolean => {
|
||||||
visited.add(nodeName);
|
visited.add(nodeName);
|
||||||
recursionStack.add(nodeName);
|
recursionStack.add(nodeName);
|
||||||
|
|
||||||
@@ -759,11 +819,23 @@ export class WorkflowValidator {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentNodeType = nodeTypeMap.get(nodeName);
|
||||||
|
const isLoopNode = loopNodeTypes.includes(currentNodeType || '');
|
||||||
|
|
||||||
for (const target of allTargets) {
|
for (const target of allTargets) {
|
||||||
if (!visited.has(target)) {
|
if (!visited.has(target)) {
|
||||||
if (hasCycleDFS(target)) return true;
|
if (hasCycleDFS(target, pathFromLoopNode || isLoopNode)) return true;
|
||||||
} else if (recursionStack.has(target)) {
|
} else if (recursionStack.has(target)) {
|
||||||
return true;
|
// Allow cycles that involve legitimate loop nodes
|
||||||
|
const targetNodeType = nodeTypeMap.get(target);
|
||||||
|
const isTargetLoopNode = loopNodeTypes.includes(targetNodeType || '');
|
||||||
|
|
||||||
|
// If this cycle involves a loop node, it's legitimate
|
||||||
|
if (isTargetLoopNode || pathFromLoopNode || isLoopNode) {
|
||||||
|
continue; // Allow this cycle
|
||||||
|
}
|
||||||
|
|
||||||
|
return true; // Reject other cycles
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -772,9 +844,9 @@ export class WorkflowValidator {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check from all nodes
|
// Check from all executable nodes (exclude sticky notes)
|
||||||
for (const node of workflow.nodes) {
|
for (const node of workflow.nodes) {
|
||||||
if (!visited.has(node.name)) {
|
if (!this.isStickyNote(node) && !visited.has(node.name)) {
|
||||||
if (hasCycleDFS(node.name)) return true;
|
if (hasCycleDFS(node.name)) return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -787,12 +859,13 @@ export class WorkflowValidator {
|
|||||||
*/
|
*/
|
||||||
private validateExpressions(
|
private validateExpressions(
|
||||||
workflow: WorkflowJson,
|
workflow: WorkflowJson,
|
||||||
result: WorkflowValidationResult
|
result: WorkflowValidationResult,
|
||||||
|
profile: string = 'runtime'
|
||||||
): void {
|
): void {
|
||||||
const nodeNames = workflow.nodes.map(n => n.name);
|
const nodeNames = workflow.nodes.map(n => n.name);
|
||||||
|
|
||||||
for (const node of workflow.nodes) {
|
for (const node of workflow.nodes) {
|
||||||
if (node.disabled) continue;
|
if (node.disabled || this.isStickyNote(node)) continue;
|
||||||
|
|
||||||
// Create expression context
|
// Create expression context
|
||||||
const context = {
|
const context = {
|
||||||
@@ -881,24 +954,28 @@ export class WorkflowValidator {
|
|||||||
*/
|
*/
|
||||||
private checkWorkflowPatterns(
|
private checkWorkflowPatterns(
|
||||||
workflow: WorkflowJson,
|
workflow: WorkflowJson,
|
||||||
result: WorkflowValidationResult
|
result: WorkflowValidationResult,
|
||||||
|
profile: string = 'runtime'
|
||||||
): void {
|
): void {
|
||||||
// Check for error handling
|
// Check for error handling
|
||||||
const hasErrorHandling = Object.values(workflow.connections).some(
|
const hasErrorHandling = Object.values(workflow.connections).some(
|
||||||
outputs => outputs.error && outputs.error.length > 0
|
outputs => outputs.error && outputs.error.length > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasErrorHandling && workflow.nodes.length > 3) {
|
// Only suggest error handling in stricter profiles
|
||||||
|
if (!hasErrorHandling && workflow.nodes.length > 3 && profile !== 'minimal') {
|
||||||
result.warnings.push({
|
result.warnings.push({
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
message: 'Consider adding error handling to your workflow'
|
message: 'Consider adding error handling to your workflow'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check node-level error handling properties for ALL nodes
|
// Check node-level error handling properties for ALL executable nodes
|
||||||
for (const node of workflow.nodes) {
|
for (const node of workflow.nodes) {
|
||||||
|
if (!this.isStickyNote(node)) {
|
||||||
this.checkNodeErrorHandling(node, workflow, result);
|
this.checkNodeErrorHandling(node, workflow, result);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for very long linear workflows
|
// Check for very long linear workflows
|
||||||
const linearChainLength = this.getLongestLinearChain(workflow);
|
const linearChainLength = this.getLongestLinearChain(workflow);
|
||||||
@@ -1470,4 +1547,205 @@ export class WorkflowValidator {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate SplitInBatches node connections for common mistakes
|
||||||
|
*/
|
||||||
|
private validateSplitInBatchesConnection(
|
||||||
|
sourceNode: WorkflowNode,
|
||||||
|
outputIndex: number,
|
||||||
|
connection: { node: string; type: string; index: number },
|
||||||
|
nodeMap: Map<string, WorkflowNode>,
|
||||||
|
result: WorkflowValidationResult
|
||||||
|
): void {
|
||||||
|
const targetNode = nodeMap.get(connection.node);
|
||||||
|
if (!targetNode) return;
|
||||||
|
|
||||||
|
// Check if connections appear to be reversed
|
||||||
|
// Output 0 = "done", Output 1 = "loop"
|
||||||
|
|
||||||
|
if (outputIndex === 0) {
|
||||||
|
// This is the "done" output (index 0)
|
||||||
|
// Check if target looks like it should be in the loop
|
||||||
|
const targetType = targetNode.type.toLowerCase();
|
||||||
|
const targetName = targetNode.name.toLowerCase();
|
||||||
|
|
||||||
|
// Common patterns that suggest this node should be inside the loop
|
||||||
|
if (targetType.includes('function') ||
|
||||||
|
targetType.includes('code') ||
|
||||||
|
targetType.includes('item') ||
|
||||||
|
targetName.includes('process') ||
|
||||||
|
targetName.includes('transform') ||
|
||||||
|
targetName.includes('handle')) {
|
||||||
|
|
||||||
|
// Check if this node connects back to the SplitInBatches
|
||||||
|
const hasLoopBack = this.checkForLoopBack(targetNode.name, sourceNode.name, nodeMap);
|
||||||
|
|
||||||
|
if (hasLoopBack) {
|
||||||
|
result.errors.push({
|
||||||
|
type: 'error',
|
||||||
|
nodeId: sourceNode.id,
|
||||||
|
nodeName: sourceNode.name,
|
||||||
|
message: `SplitInBatches outputs appear reversed! Node "${targetNode.name}" is connected to output 0 ("done") but connects back to the loop. It should be connected to output 1 ("loop") instead. Remember: Output 0 = "done" (post-loop), Output 1 = "loop" (inside loop).`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result.warnings.push({
|
||||||
|
type: 'warning',
|
||||||
|
nodeId: sourceNode.id,
|
||||||
|
nodeName: sourceNode.name,
|
||||||
|
message: `Node "${targetNode.name}" is connected to the "done" output (index 0) but appears to be a processing node. Consider connecting it to the "loop" output (index 1) if it should process items inside the loop.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (outputIndex === 1) {
|
||||||
|
// This is the "loop" output (index 1)
|
||||||
|
// Check if target looks like it should be after the loop
|
||||||
|
const targetType = targetNode.type.toLowerCase();
|
||||||
|
const targetName = targetNode.name.toLowerCase();
|
||||||
|
|
||||||
|
// Common patterns that suggest this node should be after the loop
|
||||||
|
if (targetType.includes('aggregate') ||
|
||||||
|
targetType.includes('merge') ||
|
||||||
|
targetType.includes('email') ||
|
||||||
|
targetType.includes('slack') ||
|
||||||
|
targetName.includes('final') ||
|
||||||
|
targetName.includes('complete') ||
|
||||||
|
targetName.includes('summary') ||
|
||||||
|
targetName.includes('report')) {
|
||||||
|
|
||||||
|
result.warnings.push({
|
||||||
|
type: 'warning',
|
||||||
|
nodeId: sourceNode.id,
|
||||||
|
nodeName: sourceNode.name,
|
||||||
|
message: `Node "${targetNode.name}" is connected to the "loop" output (index 1) but appears to be a post-processing node. Consider connecting it to the "done" output (index 0) if it should run after all iterations complete.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if loop output doesn't eventually connect back
|
||||||
|
const hasLoopBack = this.checkForLoopBack(targetNode.name, sourceNode.name, nodeMap);
|
||||||
|
if (!hasLoopBack) {
|
||||||
|
result.warnings.push({
|
||||||
|
type: 'warning',
|
||||||
|
nodeId: sourceNode.id,
|
||||||
|
nodeName: sourceNode.name,
|
||||||
|
message: `The "loop" output connects to "${targetNode.name}" but doesn't connect back to the SplitInBatches node. The last node in the loop should connect back to complete the iteration.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a node eventually connects back to a target node
|
||||||
|
*/
|
||||||
|
private checkForLoopBack(
|
||||||
|
startNode: string,
|
||||||
|
targetNode: string,
|
||||||
|
nodeMap: Map<string, WorkflowNode>,
|
||||||
|
visited: Set<string> = new Set(),
|
||||||
|
maxDepth: number = 50
|
||||||
|
): boolean {
|
||||||
|
if (maxDepth <= 0) return false; // Prevent stack overflow
|
||||||
|
if (visited.has(startNode)) return false;
|
||||||
|
visited.add(startNode);
|
||||||
|
|
||||||
|
const node = nodeMap.get(startNode);
|
||||||
|
if (!node) return false;
|
||||||
|
|
||||||
|
// Access connections from the workflow structure, not the node
|
||||||
|
// We need to access this.currentWorkflow.connections[startNode]
|
||||||
|
const connections = (this as any).currentWorkflow?.connections[startNode];
|
||||||
|
if (!connections) return false;
|
||||||
|
|
||||||
|
for (const [outputType, outputs] of Object.entries(connections)) {
|
||||||
|
if (!Array.isArray(outputs)) continue;
|
||||||
|
|
||||||
|
for (const outputConnections of outputs) {
|
||||||
|
if (!Array.isArray(outputConnections)) continue;
|
||||||
|
|
||||||
|
for (const conn of outputConnections) {
|
||||||
|
if (conn.node === targetNode) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively check connected nodes
|
||||||
|
if (this.checkForLoopBack(conn.node, targetNode, nodeMap, visited, maxDepth - 1)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add AI-specific error recovery suggestions
|
||||||
|
*/
|
||||||
|
private addErrorRecoverySuggestions(result: WorkflowValidationResult): void {
|
||||||
|
// Categorize errors and provide specific recovery actions
|
||||||
|
const errorTypes = {
|
||||||
|
nodeType: result.errors.filter(e => e.message.includes('node type') || e.message.includes('Node type')),
|
||||||
|
connection: result.errors.filter(e => e.message.includes('connection') || e.message.includes('Connection')),
|
||||||
|
structure: result.errors.filter(e => e.message.includes('structure') || e.message.includes('nodes must be')),
|
||||||
|
configuration: result.errors.filter(e => e.message.includes('property') || e.message.includes('field')),
|
||||||
|
typeVersion: result.errors.filter(e => e.message.includes('typeVersion'))
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add recovery suggestions based on error types
|
||||||
|
if (errorTypes.nodeType.length > 0) {
|
||||||
|
result.suggestions.unshift(
|
||||||
|
'🔧 RECOVERY: Invalid node types detected. Use these patterns:',
|
||||||
|
' • For core nodes: "n8n-nodes-base.nodeName" (e.g., "n8n-nodes-base.webhook")',
|
||||||
|
' • For AI nodes: "@n8n/n8n-nodes-langchain.nodeName"',
|
||||||
|
' • Never use just the node name without package prefix'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorTypes.connection.length > 0) {
|
||||||
|
result.suggestions.unshift(
|
||||||
|
'🔧 RECOVERY: Connection errors detected. Fix with:',
|
||||||
|
' • Use node NAMES in connections, not IDs or types',
|
||||||
|
' • Structure: { "Source Node Name": { "main": [[{ "node": "Target Node Name", "type": "main", "index": 0 }]] } }',
|
||||||
|
' • Ensure all referenced nodes exist in the workflow'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorTypes.structure.length > 0) {
|
||||||
|
result.suggestions.unshift(
|
||||||
|
'🔧 RECOVERY: Workflow structure errors. Fix with:',
|
||||||
|
' • Ensure "nodes" is an array: "nodes": [...]',
|
||||||
|
' • Ensure "connections" is an object: "connections": {...}',
|
||||||
|
' • Add at least one node to create a valid workflow'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorTypes.configuration.length > 0) {
|
||||||
|
result.suggestions.unshift(
|
||||||
|
'🔧 RECOVERY: Node configuration errors. Fix with:',
|
||||||
|
' • Check required fields using validate_node_minimal first',
|
||||||
|
' • Use get_node_essentials to see what fields are needed',
|
||||||
|
' • Ensure operation-specific fields match the node\'s requirements'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorTypes.typeVersion.length > 0) {
|
||||||
|
result.suggestions.unshift(
|
||||||
|
'🔧 RECOVERY: TypeVersion errors. Fix with:',
|
||||||
|
' • Add "typeVersion": 1 (or latest version) to each node',
|
||||||
|
' • Use get_node_info to check the correct version for each node type'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add general recovery workflow
|
||||||
|
if (result.errors.length > 3) {
|
||||||
|
result.suggestions.push(
|
||||||
|
'📋 SUGGESTED WORKFLOW: Too many errors detected. Try this approach:',
|
||||||
|
' 1. Fix structural issues first (nodes array, connections object)',
|
||||||
|
' 2. Validate node types and fix invalid ones',
|
||||||
|
' 3. Add required typeVersion to all nodes',
|
||||||
|
' 4. Test connections step by step',
|
||||||
|
' 5. Use validate_node_minimal on individual nodes to verify configuration'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -49,6 +49,7 @@ export interface Workflow {
|
|||||||
nodes: WorkflowNode[];
|
nodes: WorkflowNode[];
|
||||||
connections: WorkflowConnection;
|
connections: WorkflowConnection;
|
||||||
active?: boolean; // Optional for creation as it's read-only
|
active?: boolean; // Optional for creation as it's read-only
|
||||||
|
isArchived?: boolean; // Optional, available in newer n8n versions
|
||||||
settings?: WorkflowSettings;
|
settings?: WorkflowSettings;
|
||||||
staticData?: Record<string, unknown>;
|
staticData?: Record<string, unknown>;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
|||||||
479
src/utils/fixed-collection-validator.ts
Normal file
479
src/utils/fixed-collection-validator.ts
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
/**
|
||||||
|
* Generic utility for validating and fixing fixedCollection structures in n8n nodes
|
||||||
|
* Prevents the "propertyValues[itemName] is not iterable" error
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Type definitions for node configurations
|
||||||
|
export type NodeConfigValue = string | number | boolean | null | undefined | NodeConfig | NodeConfigValue[];
|
||||||
|
|
||||||
|
export interface NodeConfig {
|
||||||
|
[key: string]: NodeConfigValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FixedCollectionPattern {
|
||||||
|
nodeType: string;
|
||||||
|
property: string;
|
||||||
|
subProperty?: string;
|
||||||
|
expectedStructure: string;
|
||||||
|
invalidPatterns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FixedCollectionValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
errors: Array<{
|
||||||
|
pattern: string;
|
||||||
|
message: string;
|
||||||
|
fix: string;
|
||||||
|
}>;
|
||||||
|
autofix?: NodeConfig | NodeConfigValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FixedCollectionValidator {
|
||||||
|
/**
|
||||||
|
* Type guard to check if value is a NodeConfig
|
||||||
|
*/
|
||||||
|
private static isNodeConfig(value: NodeConfigValue): value is NodeConfig {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely get nested property value
|
||||||
|
*/
|
||||||
|
private static getNestedValue(obj: NodeConfig, path: string): NodeConfigValue | undefined {
|
||||||
|
const parts = path.split('.');
|
||||||
|
let current: NodeConfigValue = obj;
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!this.isNodeConfig(current)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
current = current[part];
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Known problematic patterns for various n8n nodes
|
||||||
|
*/
|
||||||
|
private static readonly KNOWN_PATTERNS: FixedCollectionPattern[] = [
|
||||||
|
// Conditional nodes (already fixed)
|
||||||
|
{
|
||||||
|
nodeType: 'switch',
|
||||||
|
property: 'rules',
|
||||||
|
expectedStructure: 'rules.values array',
|
||||||
|
invalidPatterns: ['rules.conditions', 'rules.conditions.values']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: 'if',
|
||||||
|
property: 'conditions',
|
||||||
|
expectedStructure: 'conditions array/object',
|
||||||
|
invalidPatterns: ['conditions.values']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: 'filter',
|
||||||
|
property: 'conditions',
|
||||||
|
expectedStructure: 'conditions array/object',
|
||||||
|
invalidPatterns: ['conditions.values']
|
||||||
|
},
|
||||||
|
// New nodes identified by research
|
||||||
|
{
|
||||||
|
nodeType: 'summarize',
|
||||||
|
property: 'fieldsToSummarize',
|
||||||
|
subProperty: 'values',
|
||||||
|
expectedStructure: 'fieldsToSummarize.values array',
|
||||||
|
invalidPatterns: ['fieldsToSummarize.values.values']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: 'comparedatasets',
|
||||||
|
property: 'mergeByFields',
|
||||||
|
subProperty: 'values',
|
||||||
|
expectedStructure: 'mergeByFields.values array',
|
||||||
|
invalidPatterns: ['mergeByFields.values.values']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: 'sort',
|
||||||
|
property: 'sortFieldsUi',
|
||||||
|
subProperty: 'sortField',
|
||||||
|
expectedStructure: 'sortFieldsUi.sortField array',
|
||||||
|
invalidPatterns: ['sortFieldsUi.sortField.values']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: 'aggregate',
|
||||||
|
property: 'fieldsToAggregate',
|
||||||
|
subProperty: 'fieldToAggregate',
|
||||||
|
expectedStructure: 'fieldsToAggregate.fieldToAggregate array',
|
||||||
|
invalidPatterns: ['fieldsToAggregate.fieldToAggregate.values']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: 'set',
|
||||||
|
property: 'fields',
|
||||||
|
subProperty: 'values',
|
||||||
|
expectedStructure: 'fields.values array',
|
||||||
|
invalidPatterns: ['fields.values.values']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: 'html',
|
||||||
|
property: 'extractionValues',
|
||||||
|
subProperty: 'values',
|
||||||
|
expectedStructure: 'extractionValues.values array',
|
||||||
|
invalidPatterns: ['extractionValues.values.values']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: 'httprequest',
|
||||||
|
property: 'body',
|
||||||
|
subProperty: 'parameters',
|
||||||
|
expectedStructure: 'body.parameters array',
|
||||||
|
invalidPatterns: ['body.parameters.values']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: 'airtable',
|
||||||
|
property: 'sort',
|
||||||
|
subProperty: 'sortField',
|
||||||
|
expectedStructure: 'sort.sortField array',
|
||||||
|
invalidPatterns: ['sort.sortField.values']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a node configuration for fixedCollection issues
|
||||||
|
* Includes protection against circular references
|
||||||
|
*/
|
||||||
|
static validate(
|
||||||
|
nodeType: string,
|
||||||
|
config: NodeConfig
|
||||||
|
): FixedCollectionValidationResult {
|
||||||
|
// Early return for non-object configs
|
||||||
|
if (typeof config !== 'object' || config === null || Array.isArray(config)) {
|
||||||
|
return { isValid: true, errors: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedNodeType = this.normalizeNodeType(nodeType);
|
||||||
|
const pattern = this.getPatternForNode(normalizedNodeType);
|
||||||
|
|
||||||
|
if (!pattern) {
|
||||||
|
return { isValid: true, errors: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: FixedCollectionValidationResult = {
|
||||||
|
isValid: true,
|
||||||
|
errors: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for invalid patterns
|
||||||
|
for (const invalidPattern of pattern.invalidPatterns) {
|
||||||
|
if (this.hasInvalidStructure(config, invalidPattern)) {
|
||||||
|
result.isValid = false;
|
||||||
|
result.errors.push({
|
||||||
|
pattern: invalidPattern,
|
||||||
|
message: `Invalid structure for nodes-base.${pattern.nodeType} node: found nested "${invalidPattern}" but expected "${pattern.expectedStructure}". This causes "propertyValues[itemName] is not iterable" error in n8n.`,
|
||||||
|
fix: this.generateFixMessage(pattern)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate autofix
|
||||||
|
if (!result.autofix) {
|
||||||
|
result.autofix = this.generateAutofix(config, pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply autofix to a configuration
|
||||||
|
*/
|
||||||
|
static applyAutofix(
|
||||||
|
config: NodeConfig,
|
||||||
|
pattern: FixedCollectionPattern
|
||||||
|
): NodeConfig | NodeConfigValue[] {
|
||||||
|
const fixedConfig = this.generateAutofix(config, pattern);
|
||||||
|
// For If/Filter nodes, the autofix might return just the values array
|
||||||
|
if (pattern.nodeType === 'if' || pattern.nodeType === 'filter') {
|
||||||
|
const conditions = config.conditions;
|
||||||
|
if (conditions && typeof conditions === 'object' && !Array.isArray(conditions) && 'values' in conditions) {
|
||||||
|
const values = conditions.values;
|
||||||
|
if (values !== undefined && values !== null &&
|
||||||
|
(Array.isArray(values) || typeof values === 'object')) {
|
||||||
|
return values as NodeConfig | NodeConfigValue[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fixedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize node type to handle various formats
|
||||||
|
*/
|
||||||
|
private static normalizeNodeType(nodeType: string): string {
|
||||||
|
return nodeType
|
||||||
|
.replace('n8n-nodes-base.', '')
|
||||||
|
.replace('nodes-base.', '')
|
||||||
|
.replace('@n8n/n8n-nodes-langchain.', '')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get pattern configuration for a specific node type
|
||||||
|
*/
|
||||||
|
private static getPatternForNode(nodeType: string): FixedCollectionPattern | undefined {
|
||||||
|
return this.KNOWN_PATTERNS.find(p => p.nodeType === nodeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if configuration has an invalid structure
|
||||||
|
* Includes circular reference protection
|
||||||
|
*/
|
||||||
|
private static hasInvalidStructure(
|
||||||
|
config: NodeConfig,
|
||||||
|
pattern: string
|
||||||
|
): boolean {
|
||||||
|
const parts = pattern.split('.');
|
||||||
|
let current: NodeConfigValue = config;
|
||||||
|
const visited = new WeakSet<object>();
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
// Check for null/undefined
|
||||||
|
if (current === null || current === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's an object (but not an array for property access)
|
||||||
|
if (typeof current !== 'object' || Array.isArray(current)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for circular reference
|
||||||
|
if (visited.has(current)) {
|
||||||
|
return false; // Circular reference detected, invalid structure
|
||||||
|
}
|
||||||
|
visited.add(current);
|
||||||
|
|
||||||
|
// Check if property exists (using hasOwnProperty to avoid prototype pollution)
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(current, part)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextValue = (current as NodeConfig)[part];
|
||||||
|
if (typeof nextValue !== 'object' || nextValue === null) {
|
||||||
|
// If we have more parts to traverse but current value is not an object, invalid structure
|
||||||
|
if (parts.indexOf(part) < parts.length - 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = nextValue as NodeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a fix message for the specific pattern
|
||||||
|
*/
|
||||||
|
private static generateFixMessage(pattern: FixedCollectionPattern): string {
|
||||||
|
switch (pattern.nodeType) {
|
||||||
|
case 'switch':
|
||||||
|
return 'Use: { "rules": { "values": [{ "conditions": {...}, "outputKey": "output1" }] } }';
|
||||||
|
case 'if':
|
||||||
|
case 'filter':
|
||||||
|
return 'Use: { "conditions": {...} } or { "conditions": [...] } directly, not nested under "values"';
|
||||||
|
case 'summarize':
|
||||||
|
return 'Use: { "fieldsToSummarize": { "values": [...] } } not nested values.values';
|
||||||
|
case 'comparedatasets':
|
||||||
|
return 'Use: { "mergeByFields": { "values": [...] } } not nested values.values';
|
||||||
|
case 'sort':
|
||||||
|
return 'Use: { "sortFieldsUi": { "sortField": [...] } } not sortField.values';
|
||||||
|
case 'aggregate':
|
||||||
|
return 'Use: { "fieldsToAggregate": { "fieldToAggregate": [...] } } not fieldToAggregate.values';
|
||||||
|
case 'set':
|
||||||
|
return 'Use: { "fields": { "values": [...] } } not nested values.values';
|
||||||
|
case 'html':
|
||||||
|
return 'Use: { "extractionValues": { "values": [...] } } not nested values.values';
|
||||||
|
case 'httprequest':
|
||||||
|
return 'Use: { "body": { "parameters": [...] } } not parameters.values';
|
||||||
|
case 'airtable':
|
||||||
|
return 'Use: { "sort": { "sortField": [...] } } not sortField.values';
|
||||||
|
default:
|
||||||
|
return `Use ${pattern.expectedStructure} structure`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate autofix for invalid structures
|
||||||
|
*/
|
||||||
|
private static generateAutofix(
|
||||||
|
config: NodeConfig,
|
||||||
|
pattern: FixedCollectionPattern
|
||||||
|
): NodeConfig | NodeConfigValue[] {
|
||||||
|
const fixedConfig = { ...config };
|
||||||
|
|
||||||
|
switch (pattern.nodeType) {
|
||||||
|
case 'switch': {
|
||||||
|
const rules = config.rules;
|
||||||
|
if (this.isNodeConfig(rules)) {
|
||||||
|
const conditions = rules.conditions;
|
||||||
|
if (this.isNodeConfig(conditions) && 'values' in conditions) {
|
||||||
|
const values = conditions.values;
|
||||||
|
fixedConfig.rules = {
|
||||||
|
values: Array.isArray(values)
|
||||||
|
? values.map((condition, index) => ({
|
||||||
|
conditions: condition,
|
||||||
|
outputKey: `output${index + 1}`
|
||||||
|
}))
|
||||||
|
: [{
|
||||||
|
conditions: values,
|
||||||
|
outputKey: 'output1'
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
} else if (conditions) {
|
||||||
|
fixedConfig.rules = {
|
||||||
|
values: [{
|
||||||
|
conditions: conditions,
|
||||||
|
outputKey: 'output1'
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'if':
|
||||||
|
case 'filter': {
|
||||||
|
const conditions = config.conditions;
|
||||||
|
if (this.isNodeConfig(conditions) && 'values' in conditions) {
|
||||||
|
const values = conditions.values;
|
||||||
|
if (values !== undefined && values !== null &&
|
||||||
|
(Array.isArray(values) || typeof values === 'object')) {
|
||||||
|
return values as NodeConfig | NodeConfigValue[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'summarize': {
|
||||||
|
const fieldsToSummarize = config.fieldsToSummarize;
|
||||||
|
if (this.isNodeConfig(fieldsToSummarize)) {
|
||||||
|
const values = fieldsToSummarize.values;
|
||||||
|
if (this.isNodeConfig(values) && 'values' in values) {
|
||||||
|
fixedConfig.fieldsToSummarize = {
|
||||||
|
values: values.values
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'comparedatasets': {
|
||||||
|
const mergeByFields = config.mergeByFields;
|
||||||
|
if (this.isNodeConfig(mergeByFields)) {
|
||||||
|
const values = mergeByFields.values;
|
||||||
|
if (this.isNodeConfig(values) && 'values' in values) {
|
||||||
|
fixedConfig.mergeByFields = {
|
||||||
|
values: values.values
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'sort': {
|
||||||
|
const sortFieldsUi = config.sortFieldsUi;
|
||||||
|
if (this.isNodeConfig(sortFieldsUi)) {
|
||||||
|
const sortField = sortFieldsUi.sortField;
|
||||||
|
if (this.isNodeConfig(sortField) && 'values' in sortField) {
|
||||||
|
fixedConfig.sortFieldsUi = {
|
||||||
|
sortField: sortField.values
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'aggregate': {
|
||||||
|
const fieldsToAggregate = config.fieldsToAggregate;
|
||||||
|
if (this.isNodeConfig(fieldsToAggregate)) {
|
||||||
|
const fieldToAggregate = fieldsToAggregate.fieldToAggregate;
|
||||||
|
if (this.isNodeConfig(fieldToAggregate) && 'values' in fieldToAggregate) {
|
||||||
|
fixedConfig.fieldsToAggregate = {
|
||||||
|
fieldToAggregate: fieldToAggregate.values
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'set': {
|
||||||
|
const fields = config.fields;
|
||||||
|
if (this.isNodeConfig(fields)) {
|
||||||
|
const values = fields.values;
|
||||||
|
if (this.isNodeConfig(values) && 'values' in values) {
|
||||||
|
fixedConfig.fields = {
|
||||||
|
values: values.values
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'html': {
|
||||||
|
const extractionValues = config.extractionValues;
|
||||||
|
if (this.isNodeConfig(extractionValues)) {
|
||||||
|
const values = extractionValues.values;
|
||||||
|
if (this.isNodeConfig(values) && 'values' in values) {
|
||||||
|
fixedConfig.extractionValues = {
|
||||||
|
values: values.values
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'httprequest': {
|
||||||
|
const body = config.body;
|
||||||
|
if (this.isNodeConfig(body)) {
|
||||||
|
const parameters = body.parameters;
|
||||||
|
if (this.isNodeConfig(parameters) && 'values' in parameters) {
|
||||||
|
fixedConfig.body = {
|
||||||
|
...body,
|
||||||
|
parameters: parameters.values
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'airtable': {
|
||||||
|
const sort = config.sort;
|
||||||
|
if (this.isNodeConfig(sort)) {
|
||||||
|
const sortField = sort.sortField;
|
||||||
|
if (this.isNodeConfig(sortField) && 'values' in sortField) {
|
||||||
|
fixedConfig.sort = {
|
||||||
|
sortField: sortField.values
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fixedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all known patterns (for testing and documentation)
|
||||||
|
* Returns a deep copy to prevent external modifications
|
||||||
|
*/
|
||||||
|
static getAllPatterns(): FixedCollectionPattern[] {
|
||||||
|
return this.KNOWN_PATTERNS.map(pattern => ({
|
||||||
|
...pattern,
|
||||||
|
invalidPatterns: [...pattern.invalidPatterns]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a node type is susceptible to fixedCollection issues
|
||||||
|
*/
|
||||||
|
static isNodeSusceptible(nodeType: string): boolean {
|
||||||
|
const normalizedType = this.normalizeNodeType(nodeType);
|
||||||
|
return this.KNOWN_PATTERNS.some(p => p.nodeType === normalizedType);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
export class SimpleCache {
|
export class SimpleCache {
|
||||||
private cache = new Map<string, { data: any; expires: number }>();
|
private cache = new Map<string, { data: any; expires: number }>();
|
||||||
|
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Clean up expired entries every minute
|
// Clean up expired entries every minute
|
||||||
setInterval(() => {
|
this.cleanupTimer = setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [key, item] of this.cache.entries()) {
|
for (const [key, item] of this.cache.entries()) {
|
||||||
if (item.expires < now) this.cache.delete(key);
|
if (item.expires < now) this.cache.delete(key);
|
||||||
@@ -34,4 +35,16 @@ export class SimpleCache {
|
|||||||
clear(): void {
|
clear(): void {
|
||||||
this.cache.clear();
|
this.cache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up the cache and stop the cleanup timer
|
||||||
|
* Essential for preventing memory leaks in long-running servers
|
||||||
|
*/
|
||||||
|
destroy(): void {
|
||||||
|
if (this.cleanupTimer) {
|
||||||
|
clearInterval(this.cleanupTimer);
|
||||||
|
this.cleanupTimer = null;
|
||||||
|
}
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
312
src/utils/validation-schemas.ts
Normal file
312
src/utils/validation-schemas.ts
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
/**
|
||||||
|
* Zod validation schemas for MCP tool parameters
|
||||||
|
* Provides robust input validation with detailed error messages
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Simple validation without zod for now, since it's not installed
|
||||||
|
// We can use TypeScript's built-in validation with better error messages
|
||||||
|
|
||||||
|
export class ValidationError extends Error {
|
||||||
|
constructor(message: string, public field?: string, public value?: any) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ValidationError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: Array<{
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
value?: any;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basic validation utilities
|
||||||
|
*/
|
||||||
|
export class Validator {
|
||||||
|
/**
|
||||||
|
* Validate that a value is a non-empty string
|
||||||
|
*/
|
||||||
|
static validateString(value: any, fieldName: string, required: boolean = true): ValidationResult {
|
||||||
|
const errors: Array<{field: string, message: string, value?: any}> = [];
|
||||||
|
|
||||||
|
if (required && (value === undefined || value === null)) {
|
||||||
|
errors.push({
|
||||||
|
field: fieldName,
|
||||||
|
message: `${fieldName} is required`,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
} else if (value !== undefined && value !== null && typeof value !== 'string') {
|
||||||
|
errors.push({
|
||||||
|
field: fieldName,
|
||||||
|
message: `${fieldName} must be a string, got ${typeof value}`,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
} else if (required && typeof value === 'string' && value.trim().length === 0) {
|
||||||
|
errors.push({
|
||||||
|
field: fieldName,
|
||||||
|
message: `${fieldName} cannot be empty`,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a value is a valid object (not null, not array)
|
||||||
|
*/
|
||||||
|
static validateObject(value: any, fieldName: string, required: boolean = true): ValidationResult {
|
||||||
|
const errors: Array<{field: string, message: string, value?: any}> = [];
|
||||||
|
|
||||||
|
if (required && (value === undefined || value === null)) {
|
||||||
|
errors.push({
|
||||||
|
field: fieldName,
|
||||||
|
message: `${fieldName} is required`,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
} else if (value !== undefined && value !== null) {
|
||||||
|
if (typeof value !== 'object') {
|
||||||
|
errors.push({
|
||||||
|
field: fieldName,
|
||||||
|
message: `${fieldName} must be an object, got ${typeof value}`,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
errors.push({
|
||||||
|
field: fieldName,
|
||||||
|
message: `${fieldName} must be an object, not an array`,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a value is an array
|
||||||
|
*/
|
||||||
|
static validateArray(value: any, fieldName: string, required: boolean = true): ValidationResult {
|
||||||
|
const errors: Array<{field: string, message: string, value?: any}> = [];
|
||||||
|
|
||||||
|
if (required && (value === undefined || value === null)) {
|
||||||
|
errors.push({
|
||||||
|
field: fieldName,
|
||||||
|
message: `${fieldName} is required`,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
} else if (value !== undefined && value !== null && !Array.isArray(value)) {
|
||||||
|
errors.push({
|
||||||
|
field: fieldName,
|
||||||
|
message: `${fieldName} must be an array, got ${typeof value}`,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a value is a number
|
||||||
|
*/
|
||||||
|
static validateNumber(value: any, fieldName: string, required: boolean = true, min?: number, max?: number): ValidationResult {
|
||||||
|
const errors: Array<{field: string, message: string, value?: any}> = [];
|
||||||
|
|
||||||
|
if (required && (value === undefined || value === null)) {
|
||||||
|
errors.push({
|
||||||
|
field: fieldName,
|
||||||
|
message: `${fieldName} is required`,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
} else if (value !== undefined && value !== null) {
|
||||||
|
if (typeof value !== 'number' || isNaN(value)) {
|
||||||
|
errors.push({
|
||||||
|
field: fieldName,
|
||||||
|
message: `${fieldName} must be a number, got ${typeof value}`,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (min !== undefined && value < min) {
|
||||||
|
errors.push({
|
||||||
|
field: fieldName,
|
||||||
|
message: `${fieldName} must be at least ${min}, got ${value}`,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (max !== undefined && value > max) {
|
||||||
|
errors.push({
|
||||||
|
field: fieldName,
|
||||||
|
message: `${fieldName} must be at most ${max}, got ${value}`,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a value is one of allowed values
|
||||||
|
*/
|
||||||
|
static validateEnum<T>(value: any, fieldName: string, allowedValues: T[], required: boolean = true): ValidationResult {
|
||||||
|
const errors: Array<{field: string, message: string, value?: any}> = [];
|
||||||
|
|
||||||
|
if (required && (value === undefined || value === null)) {
|
||||||
|
errors.push({
|
||||||
|
field: fieldName,
|
||||||
|
message: `${fieldName} is required`,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
} else if (value !== undefined && value !== null && !allowedValues.includes(value)) {
|
||||||
|
errors.push({
|
||||||
|
field: fieldName,
|
||||||
|
message: `${fieldName} must be one of: ${allowedValues.join(', ')}, got "${value}"`,
|
||||||
|
value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combine multiple validation results
|
||||||
|
*/
|
||||||
|
static combineResults(...results: ValidationResult[]): ValidationResult {
|
||||||
|
const allErrors = results.flatMap(r => r.errors);
|
||||||
|
return {
|
||||||
|
valid: allErrors.length === 0,
|
||||||
|
errors: allErrors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a detailed error message from validation result
|
||||||
|
*/
|
||||||
|
static formatErrors(result: ValidationResult, toolName?: string): string {
|
||||||
|
if (result.valid) return '';
|
||||||
|
|
||||||
|
const prefix = toolName ? `${toolName}: ` : '';
|
||||||
|
const errors = result.errors.map(e => ` • ${e.field}: ${e.message}`).join('\n');
|
||||||
|
|
||||||
|
return `${prefix}Validation failed:\n${errors}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool-specific validation schemas
|
||||||
|
*/
|
||||||
|
export class ToolValidation {
|
||||||
|
/**
|
||||||
|
* Validate parameters for validate_node_operation tool
|
||||||
|
*/
|
||||||
|
static validateNodeOperation(args: any): ValidationResult {
|
||||||
|
const nodeTypeResult = Validator.validateString(args.nodeType, 'nodeType');
|
||||||
|
const configResult = Validator.validateObject(args.config, 'config');
|
||||||
|
const profileResult = Validator.validateEnum(
|
||||||
|
args.profile,
|
||||||
|
'profile',
|
||||||
|
['minimal', 'runtime', 'ai-friendly', 'strict'],
|
||||||
|
false // optional
|
||||||
|
);
|
||||||
|
|
||||||
|
return Validator.combineResults(nodeTypeResult, configResult, profileResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate parameters for validate_node_minimal tool
|
||||||
|
*/
|
||||||
|
static validateNodeMinimal(args: any): ValidationResult {
|
||||||
|
const nodeTypeResult = Validator.validateString(args.nodeType, 'nodeType');
|
||||||
|
const configResult = Validator.validateObject(args.config, 'config');
|
||||||
|
|
||||||
|
return Validator.combineResults(nodeTypeResult, configResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate parameters for validate_workflow tool
|
||||||
|
*/
|
||||||
|
static validateWorkflow(args: any): ValidationResult {
|
||||||
|
const workflowResult = Validator.validateObject(args.workflow, 'workflow');
|
||||||
|
|
||||||
|
// Validate workflow structure if it's an object
|
||||||
|
let nodesResult: ValidationResult = { valid: true, errors: [] };
|
||||||
|
let connectionsResult: ValidationResult = { valid: true, errors: [] };
|
||||||
|
|
||||||
|
if (workflowResult.valid && args.workflow) {
|
||||||
|
nodesResult = Validator.validateArray(args.workflow.nodes, 'workflow.nodes');
|
||||||
|
connectionsResult = Validator.validateObject(args.workflow.connections, 'workflow.connections');
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionsResult = args.options ?
|
||||||
|
Validator.validateObject(args.options, 'options', false) :
|
||||||
|
{ valid: true, errors: [] };
|
||||||
|
|
||||||
|
return Validator.combineResults(workflowResult, nodesResult, connectionsResult, optionsResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate parameters for search_nodes tool
|
||||||
|
*/
|
||||||
|
static validateSearchNodes(args: any): ValidationResult {
|
||||||
|
const queryResult = Validator.validateString(args.query, 'query');
|
||||||
|
const limitResult = Validator.validateNumber(args.limit, 'limit', false, 1, 200);
|
||||||
|
const modeResult = Validator.validateEnum(
|
||||||
|
args.mode,
|
||||||
|
'mode',
|
||||||
|
['OR', 'AND', 'FUZZY'],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
return Validator.combineResults(queryResult, limitResult, modeResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate parameters for list_node_templates tool
|
||||||
|
*/
|
||||||
|
static validateListNodeTemplates(args: any): ValidationResult {
|
||||||
|
const nodeTypesResult = Validator.validateArray(args.nodeTypes, 'nodeTypes');
|
||||||
|
const limitResult = Validator.validateNumber(args.limit, 'limit', false, 1, 50);
|
||||||
|
|
||||||
|
return Validator.combineResults(nodeTypesResult, limitResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate parameters for n8n workflow operations
|
||||||
|
*/
|
||||||
|
static validateWorkflowId(args: any): ValidationResult {
|
||||||
|
return Validator.validateString(args.id, 'id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate parameters for n8n_create_workflow tool
|
||||||
|
*/
|
||||||
|
static validateCreateWorkflow(args: any): ValidationResult {
|
||||||
|
const nameResult = Validator.validateString(args.name, 'name');
|
||||||
|
const nodesResult = Validator.validateArray(args.nodes, 'nodes');
|
||||||
|
const connectionsResult = Validator.validateObject(args.connections, 'connections');
|
||||||
|
const settingsResult = args.settings ?
|
||||||
|
Validator.validateObject(args.settings, 'settings', false) :
|
||||||
|
{ valid: true, errors: [] };
|
||||||
|
|
||||||
|
return Validator.combineResults(nameResult, nodesResult, connectionsResult, settingsResult);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,16 +109,16 @@ describe('MCP Error Handling', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle empty search query', async () => {
|
it('should handle empty search query', async () => {
|
||||||
// Empty query returns empty results
|
try {
|
||||||
const response = await client.callTool({ name: 'search_nodes', arguments: {
|
await client.callTool({ name: 'search_nodes', arguments: {
|
||||||
query: ''
|
query: ''
|
||||||
} });
|
} });
|
||||||
|
expect.fail('Should have thrown an error');
|
||||||
const result = JSON.parse((response as any).content[0].text);
|
} catch (error: any) {
|
||||||
// search_nodes returns 'results' not 'nodes'
|
expect(error).toBeDefined();
|
||||||
expect(result).toHaveProperty('results');
|
expect(error.message).toContain("search_nodes: Validation failed:");
|
||||||
expect(Array.isArray(result.results)).toBe(true);
|
expect(error.message).toContain("query: query cannot be empty");
|
||||||
expect(result.results).toHaveLength(0);
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle non-existent node types', async () => {
|
it('should handle non-existent node types', async () => {
|
||||||
@@ -149,19 +149,19 @@ describe('MCP Error Handling', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should handle malformed workflow structure', async () => {
|
it('should handle malformed workflow structure', async () => {
|
||||||
const response = await client.callTool({ name: 'validate_workflow', arguments: {
|
try {
|
||||||
|
await client.callTool({ name: 'validate_workflow', arguments: {
|
||||||
workflow: {
|
workflow: {
|
||||||
// Missing required 'nodes' array
|
// Missing required 'nodes' array
|
||||||
connections: {}
|
connections: {}
|
||||||
}
|
}
|
||||||
} });
|
} });
|
||||||
|
expect.fail('Should have thrown an error');
|
||||||
// Should return validation error, not throw
|
} catch (error: any) {
|
||||||
const validation = JSON.parse((response as any).content[0].text);
|
expect(error).toBeDefined();
|
||||||
expect(validation.valid).toBe(false);
|
expect(error.message).toContain("validate_workflow: Validation failed:");
|
||||||
expect(validation.errors).toBeDefined();
|
expect(error.message).toContain("workflow.nodes: workflow.nodes is required");
|
||||||
expect(validation.errors.length).toBeGreaterThan(0);
|
}
|
||||||
expect(validation.errors[0].message).toContain('nodes');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle circular workflow references', async () => {
|
it('should handle circular workflow references', async () => {
|
||||||
@@ -501,7 +501,8 @@ describe('MCP Error Handling', () => {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
expect(error).toBeDefined();
|
expect(error).toBeDefined();
|
||||||
// The error now properly validates required parameters
|
// The error now properly validates required parameters
|
||||||
expect(error.message).toContain("Missing required parameters");
|
expect(error.message).toContain("search_nodes: Validation failed:");
|
||||||
|
expect(error.message).toContain("query: query is required");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -124,9 +124,9 @@ describe('MCP Tool Invocation', () => {
|
|||||||
const andNodes = andResult.results;
|
const andNodes = andResult.results;
|
||||||
expect(andNodes.length).toBeLessThanOrEqual(orNodes.length);
|
expect(andNodes.length).toBeLessThanOrEqual(orNodes.length);
|
||||||
|
|
||||||
// FUZZY mode
|
// FUZZY mode - use less typo-heavy search
|
||||||
const fuzzyResponse = await client.callTool({ name: 'search_nodes', arguments: {
|
const fuzzyResponse = await client.callTool({ name: 'search_nodes', arguments: {
|
||||||
query: 'htpp requst', // Intentional typos
|
query: 'http req', // Partial match should work
|
||||||
mode: 'FUZZY'
|
mode: 'FUZZY'
|
||||||
}});
|
}});
|
||||||
const fuzzyResult = JSON.parse(((fuzzyResponse as any).content[0]).text);
|
const fuzzyResult = JSON.parse(((fuzzyResponse as any).content[0]).text);
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ describe('NodeRepository - Core Functionality', () => {
|
|||||||
isWebhook: false,
|
isWebhook: false,
|
||||||
isVersioned: true,
|
isVersioned: true,
|
||||||
version: '1.0',
|
version: '1.0',
|
||||||
documentation: 'HTTP Request documentation'
|
documentation: 'HTTP Request documentation',
|
||||||
|
outputs: undefined,
|
||||||
|
outputNames: undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
repository.saveNode(parsedNode);
|
repository.saveNode(parsedNode);
|
||||||
@@ -108,7 +110,9 @@ describe('NodeRepository - Core Functionality', () => {
|
|||||||
'HTTP Request documentation',
|
'HTTP Request documentation',
|
||||||
JSON.stringify([{ name: 'url', type: 'string' }], null, 2),
|
JSON.stringify([{ name: 'url', type: 'string' }], null, 2),
|
||||||
JSON.stringify([{ name: 'execute', displayName: 'Execute' }], null, 2),
|
JSON.stringify([{ name: 'execute', displayName: 'Execute' }], null, 2),
|
||||||
JSON.stringify([{ name: 'httpBasicAuth' }], null, 2)
|
JSON.stringify([{ name: 'httpBasicAuth' }], null, 2),
|
||||||
|
null, // outputs
|
||||||
|
null // outputNames
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,7 +129,9 @@ describe('NodeRepository - Core Functionality', () => {
|
|||||||
isAITool: true,
|
isAITool: true,
|
||||||
isTrigger: true,
|
isTrigger: true,
|
||||||
isWebhook: true,
|
isWebhook: true,
|
||||||
isVersioned: false
|
isVersioned: false,
|
||||||
|
outputs: undefined,
|
||||||
|
outputNames: undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
repository.saveNode(minimalNode);
|
repository.saveNode(minimalNode);
|
||||||
@@ -157,7 +163,9 @@ describe('NodeRepository - Core Functionality', () => {
|
|||||||
properties_schema: JSON.stringify([{ name: 'url', type: 'string' }]),
|
properties_schema: JSON.stringify([{ name: 'url', type: 'string' }]),
|
||||||
operations: JSON.stringify([{ name: 'execute' }]),
|
operations: JSON.stringify([{ name: 'execute' }]),
|
||||||
credentials_required: JSON.stringify([{ name: 'httpBasicAuth' }]),
|
credentials_required: JSON.stringify([{ name: 'httpBasicAuth' }]),
|
||||||
documentation: 'HTTP docs'
|
documentation: 'HTTP docs',
|
||||||
|
outputs: null,
|
||||||
|
output_names: null
|
||||||
};
|
};
|
||||||
|
|
||||||
mockAdapter._setMockData('node:nodes-base.httpRequest', mockRow);
|
mockAdapter._setMockData('node:nodes-base.httpRequest', mockRow);
|
||||||
@@ -179,7 +187,9 @@ describe('NodeRepository - Core Functionality', () => {
|
|||||||
properties: [{ name: 'url', type: 'string' }],
|
properties: [{ name: 'url', type: 'string' }],
|
||||||
operations: [{ name: 'execute' }],
|
operations: [{ name: 'execute' }],
|
||||||
credentials: [{ name: 'httpBasicAuth' }],
|
credentials: [{ name: 'httpBasicAuth' }],
|
||||||
hasDocumentation: true
|
hasDocumentation: true,
|
||||||
|
outputs: null,
|
||||||
|
outputNames: null
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -204,7 +214,9 @@ describe('NodeRepository - Core Functionality', () => {
|
|||||||
properties_schema: '{invalid json',
|
properties_schema: '{invalid json',
|
||||||
operations: 'not json at all',
|
operations: 'not json at all',
|
||||||
credentials_required: '{"valid": "json"}',
|
credentials_required: '{"valid": "json"}',
|
||||||
documentation: null
|
documentation: null,
|
||||||
|
outputs: null,
|
||||||
|
output_names: null
|
||||||
};
|
};
|
||||||
|
|
||||||
mockAdapter._setMockData('node:nodes-base.broken', mockRow);
|
mockAdapter._setMockData('node:nodes-base.broken', mockRow);
|
||||||
@@ -320,7 +332,9 @@ describe('NodeRepository - Core Functionality', () => {
|
|||||||
isAITool: false,
|
isAITool: false,
|
||||||
isTrigger: false,
|
isTrigger: false,
|
||||||
isWebhook: false,
|
isWebhook: false,
|
||||||
isVersioned: false
|
isVersioned: false,
|
||||||
|
outputs: undefined,
|
||||||
|
outputNames: undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
repository.saveNode(node);
|
repository.saveNode(node);
|
||||||
@@ -348,7 +362,9 @@ describe('NodeRepository - Core Functionality', () => {
|
|||||||
properties_schema: '[]',
|
properties_schema: '[]',
|
||||||
operations: '[]',
|
operations: '[]',
|
||||||
credentials_required: '[]',
|
credentials_required: '[]',
|
||||||
documentation: null
|
documentation: null,
|
||||||
|
outputs: null,
|
||||||
|
output_names: null
|
||||||
};
|
};
|
||||||
|
|
||||||
mockAdapter._setMockData('node:nodes-base.bool-test', mockRow);
|
mockAdapter._setMockData('node:nodes-base.bool-test', mockRow);
|
||||||
|
|||||||
568
tests/unit/database/node-repository-outputs.test.ts
Normal file
568
tests/unit/database/node-repository-outputs.test.ts
Normal file
@@ -0,0 +1,568 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { NodeRepository } from '@/database/node-repository';
|
||||||
|
import { DatabaseAdapter } from '@/database/database-adapter';
|
||||||
|
import { ParsedNode } from '@/parsers/node-parser';
|
||||||
|
|
||||||
|
describe('NodeRepository - Outputs Handling', () => {
|
||||||
|
let repository: NodeRepository;
|
||||||
|
let mockDb: DatabaseAdapter;
|
||||||
|
let mockStatement: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockStatement = {
|
||||||
|
run: vi.fn(),
|
||||||
|
get: vi.fn(),
|
||||||
|
all: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
mockDb = {
|
||||||
|
prepare: vi.fn().mockReturnValue(mockStatement),
|
||||||
|
transaction: vi.fn(),
|
||||||
|
exec: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
pragma: vi.fn()
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
repository = new NodeRepository(mockDb);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saveNode with outputs', () => {
|
||||||
|
it('should save node with outputs and outputNames correctly', () => {
|
||||||
|
const outputs = [
|
||||||
|
{ displayName: 'Done', description: 'Final results when loop completes' },
|
||||||
|
{ displayName: 'Loop', description: 'Current batch data during iteration' }
|
||||||
|
];
|
||||||
|
const outputNames = ['done', 'loop'];
|
||||||
|
|
||||||
|
const node: ParsedNode = {
|
||||||
|
style: 'programmatic',
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
displayName: 'Split In Batches',
|
||||||
|
description: 'Split data into batches',
|
||||||
|
category: 'transform',
|
||||||
|
properties: [],
|
||||||
|
credentials: [],
|
||||||
|
isAITool: false,
|
||||||
|
isTrigger: false,
|
||||||
|
isWebhook: false,
|
||||||
|
operations: [],
|
||||||
|
version: '3',
|
||||||
|
isVersioned: false,
|
||||||
|
packageName: 'n8n-nodes-base',
|
||||||
|
outputs,
|
||||||
|
outputNames
|
||||||
|
};
|
||||||
|
|
||||||
|
repository.saveNode(node);
|
||||||
|
|
||||||
|
expect(mockDb.prepare).toHaveBeenCalledWith(`
|
||||||
|
INSERT OR REPLACE INTO nodes (
|
||||||
|
node_type, package_name, display_name, description,
|
||||||
|
category, development_style, is_ai_tool, is_trigger,
|
||||||
|
is_webhook, is_versioned, version, documentation,
|
||||||
|
properties_schema, operations, credentials_required,
|
||||||
|
outputs, output_names
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(mockStatement.run).toHaveBeenCalledWith(
|
||||||
|
'nodes-base.splitInBatches',
|
||||||
|
'n8n-nodes-base',
|
||||||
|
'Split In Batches',
|
||||||
|
'Split data into batches',
|
||||||
|
'transform',
|
||||||
|
'programmatic',
|
||||||
|
0, // false
|
||||||
|
0, // false
|
||||||
|
0, // false
|
||||||
|
0, // false
|
||||||
|
'3',
|
||||||
|
null, // documentation
|
||||||
|
JSON.stringify([], null, 2), // properties
|
||||||
|
JSON.stringify([], null, 2), // operations
|
||||||
|
JSON.stringify([], null, 2), // credentials
|
||||||
|
JSON.stringify(outputs, null, 2), // outputs
|
||||||
|
JSON.stringify(outputNames, null, 2) // output_names
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should save node with only outputs (no outputNames)', () => {
|
||||||
|
const outputs = [
|
||||||
|
{ displayName: 'True', description: 'Items that match condition' },
|
||||||
|
{ displayName: 'False', description: 'Items that do not match condition' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const node: ParsedNode = {
|
||||||
|
style: 'programmatic',
|
||||||
|
nodeType: 'nodes-base.if',
|
||||||
|
displayName: 'IF',
|
||||||
|
description: 'Route items based on conditions',
|
||||||
|
category: 'transform',
|
||||||
|
properties: [],
|
||||||
|
credentials: [],
|
||||||
|
isAITool: false,
|
||||||
|
isTrigger: false,
|
||||||
|
isWebhook: false,
|
||||||
|
operations: [],
|
||||||
|
version: '2',
|
||||||
|
isVersioned: false,
|
||||||
|
packageName: 'n8n-nodes-base',
|
||||||
|
outputs
|
||||||
|
// no outputNames
|
||||||
|
};
|
||||||
|
|
||||||
|
repository.saveNode(node);
|
||||||
|
|
||||||
|
const callArgs = mockStatement.run.mock.calls[0];
|
||||||
|
expect(callArgs[15]).toBe(JSON.stringify(outputs, null, 2)); // outputs
|
||||||
|
expect(callArgs[16]).toBe(null); // output_names should be null
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should save node with only outputNames (no outputs)', () => {
|
||||||
|
const outputNames = ['main', 'error'];
|
||||||
|
|
||||||
|
const node: ParsedNode = {
|
||||||
|
style: 'programmatic',
|
||||||
|
nodeType: 'nodes-base.customNode',
|
||||||
|
displayName: 'Custom Node',
|
||||||
|
description: 'Custom node with output names only',
|
||||||
|
category: 'transform',
|
||||||
|
properties: [],
|
||||||
|
credentials: [],
|
||||||
|
isAITool: false,
|
||||||
|
isTrigger: false,
|
||||||
|
isWebhook: false,
|
||||||
|
operations: [],
|
||||||
|
version: '1',
|
||||||
|
isVersioned: false,
|
||||||
|
packageName: 'n8n-nodes-base',
|
||||||
|
outputNames
|
||||||
|
// no outputs
|
||||||
|
};
|
||||||
|
|
||||||
|
repository.saveNode(node);
|
||||||
|
|
||||||
|
const callArgs = mockStatement.run.mock.calls[0];
|
||||||
|
expect(callArgs[15]).toBe(null); // outputs should be null
|
||||||
|
expect(callArgs[16]).toBe(JSON.stringify(outputNames, null, 2)); // output_names
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should save node without outputs or outputNames', () => {
|
||||||
|
const node: ParsedNode = {
|
||||||
|
style: 'programmatic',
|
||||||
|
nodeType: 'nodes-base.httpRequest',
|
||||||
|
displayName: 'HTTP Request',
|
||||||
|
description: 'Make HTTP requests',
|
||||||
|
category: 'input',
|
||||||
|
properties: [],
|
||||||
|
credentials: [],
|
||||||
|
isAITool: false,
|
||||||
|
isTrigger: false,
|
||||||
|
isWebhook: false,
|
||||||
|
operations: [],
|
||||||
|
version: '4',
|
||||||
|
isVersioned: false,
|
||||||
|
packageName: 'n8n-nodes-base'
|
||||||
|
// no outputs or outputNames
|
||||||
|
};
|
||||||
|
|
||||||
|
repository.saveNode(node);
|
||||||
|
|
||||||
|
const callArgs = mockStatement.run.mock.calls[0];
|
||||||
|
expect(callArgs[15]).toBe(null); // outputs should be null
|
||||||
|
expect(callArgs[16]).toBe(null); // output_names should be null
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty outputs and outputNames arrays', () => {
|
||||||
|
const node: ParsedNode = {
|
||||||
|
style: 'programmatic',
|
||||||
|
nodeType: 'nodes-base.emptyNode',
|
||||||
|
displayName: 'Empty Node',
|
||||||
|
description: 'Node with empty outputs',
|
||||||
|
category: 'misc',
|
||||||
|
properties: [],
|
||||||
|
credentials: [],
|
||||||
|
isAITool: false,
|
||||||
|
isTrigger: false,
|
||||||
|
isWebhook: false,
|
||||||
|
operations: [],
|
||||||
|
version: '1',
|
||||||
|
isVersioned: false,
|
||||||
|
packageName: 'n8n-nodes-base',
|
||||||
|
outputs: [],
|
||||||
|
outputNames: []
|
||||||
|
};
|
||||||
|
|
||||||
|
repository.saveNode(node);
|
||||||
|
|
||||||
|
const callArgs = mockStatement.run.mock.calls[0];
|
||||||
|
expect(callArgs[15]).toBe(JSON.stringify([], null, 2)); // outputs
|
||||||
|
expect(callArgs[16]).toBe(JSON.stringify([], null, 2)); // output_names
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNode with outputs', () => {
|
||||||
|
it('should retrieve node with outputs and outputNames correctly', () => {
|
||||||
|
const outputs = [
|
||||||
|
{ displayName: 'Done', description: 'Final results when loop completes' },
|
||||||
|
{ displayName: 'Loop', description: 'Current batch data during iteration' }
|
||||||
|
];
|
||||||
|
const outputNames = ['done', 'loop'];
|
||||||
|
|
||||||
|
const mockRow = {
|
||||||
|
node_type: 'nodes-base.splitInBatches',
|
||||||
|
display_name: 'Split In Batches',
|
||||||
|
description: 'Split data into batches',
|
||||||
|
category: 'transform',
|
||||||
|
development_style: 'programmatic',
|
||||||
|
package_name: 'n8n-nodes-base',
|
||||||
|
is_ai_tool: 0,
|
||||||
|
is_trigger: 0,
|
||||||
|
is_webhook: 0,
|
||||||
|
is_versioned: 0,
|
||||||
|
version: '3',
|
||||||
|
properties_schema: JSON.stringify([]),
|
||||||
|
operations: JSON.stringify([]),
|
||||||
|
credentials_required: JSON.stringify([]),
|
||||||
|
documentation: null,
|
||||||
|
outputs: JSON.stringify(outputs),
|
||||||
|
output_names: JSON.stringify(outputNames)
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStatement.get.mockReturnValue(mockRow);
|
||||||
|
|
||||||
|
const result = repository.getNode('nodes-base.splitInBatches');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
displayName: 'Split In Batches',
|
||||||
|
description: 'Split data into batches',
|
||||||
|
category: 'transform',
|
||||||
|
developmentStyle: 'programmatic',
|
||||||
|
package: 'n8n-nodes-base',
|
||||||
|
isAITool: false,
|
||||||
|
isTrigger: false,
|
||||||
|
isWebhook: false,
|
||||||
|
isVersioned: false,
|
||||||
|
version: '3',
|
||||||
|
properties: [],
|
||||||
|
operations: [],
|
||||||
|
credentials: [],
|
||||||
|
hasDocumentation: false,
|
||||||
|
outputs,
|
||||||
|
outputNames
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should retrieve node with only outputs (null outputNames)', () => {
|
||||||
|
const outputs = [
|
||||||
|
{ displayName: 'True', description: 'Items that match condition' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockRow = {
|
||||||
|
node_type: 'nodes-base.if',
|
||||||
|
display_name: 'IF',
|
||||||
|
description: 'Route items',
|
||||||
|
category: 'transform',
|
||||||
|
development_style: 'programmatic',
|
||||||
|
package_name: 'n8n-nodes-base',
|
||||||
|
is_ai_tool: 0,
|
||||||
|
is_trigger: 0,
|
||||||
|
is_webhook: 0,
|
||||||
|
is_versioned: 0,
|
||||||
|
version: '2',
|
||||||
|
properties_schema: JSON.stringify([]),
|
||||||
|
operations: JSON.stringify([]),
|
||||||
|
credentials_required: JSON.stringify([]),
|
||||||
|
documentation: null,
|
||||||
|
outputs: JSON.stringify(outputs),
|
||||||
|
output_names: null
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStatement.get.mockReturnValue(mockRow);
|
||||||
|
|
||||||
|
const result = repository.getNode('nodes-base.if');
|
||||||
|
|
||||||
|
expect(result.outputs).toEqual(outputs);
|
||||||
|
expect(result.outputNames).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should retrieve node with only outputNames (null outputs)', () => {
|
||||||
|
const outputNames = ['main'];
|
||||||
|
|
||||||
|
const mockRow = {
|
||||||
|
node_type: 'nodes-base.customNode',
|
||||||
|
display_name: 'Custom Node',
|
||||||
|
description: 'Custom node',
|
||||||
|
category: 'misc',
|
||||||
|
development_style: 'programmatic',
|
||||||
|
package_name: 'n8n-nodes-base',
|
||||||
|
is_ai_tool: 0,
|
||||||
|
is_trigger: 0,
|
||||||
|
is_webhook: 0,
|
||||||
|
is_versioned: 0,
|
||||||
|
version: '1',
|
||||||
|
properties_schema: JSON.stringify([]),
|
||||||
|
operations: JSON.stringify([]),
|
||||||
|
credentials_required: JSON.stringify([]),
|
||||||
|
documentation: null,
|
||||||
|
outputs: null,
|
||||||
|
output_names: JSON.stringify(outputNames)
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStatement.get.mockReturnValue(mockRow);
|
||||||
|
|
||||||
|
const result = repository.getNode('nodes-base.customNode');
|
||||||
|
|
||||||
|
expect(result.outputs).toBe(null);
|
||||||
|
expect(result.outputNames).toEqual(outputNames);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should retrieve node without outputs or outputNames', () => {
|
||||||
|
const mockRow = {
|
||||||
|
node_type: 'nodes-base.httpRequest',
|
||||||
|
display_name: 'HTTP Request',
|
||||||
|
description: 'Make HTTP requests',
|
||||||
|
category: 'input',
|
||||||
|
development_style: 'programmatic',
|
||||||
|
package_name: 'n8n-nodes-base',
|
||||||
|
is_ai_tool: 0,
|
||||||
|
is_trigger: 0,
|
||||||
|
is_webhook: 0,
|
||||||
|
is_versioned: 0,
|
||||||
|
version: '4',
|
||||||
|
properties_schema: JSON.stringify([]),
|
||||||
|
operations: JSON.stringify([]),
|
||||||
|
credentials_required: JSON.stringify([]),
|
||||||
|
documentation: null,
|
||||||
|
outputs: null,
|
||||||
|
output_names: null
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStatement.get.mockReturnValue(mockRow);
|
||||||
|
|
||||||
|
const result = repository.getNode('nodes-base.httpRequest');
|
||||||
|
|
||||||
|
expect(result.outputs).toBe(null);
|
||||||
|
expect(result.outputNames).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed JSON gracefully', () => {
|
||||||
|
const mockRow = {
|
||||||
|
node_type: 'nodes-base.malformed',
|
||||||
|
display_name: 'Malformed Node',
|
||||||
|
description: 'Node with malformed JSON',
|
||||||
|
category: 'misc',
|
||||||
|
development_style: 'programmatic',
|
||||||
|
package_name: 'n8n-nodes-base',
|
||||||
|
is_ai_tool: 0,
|
||||||
|
is_trigger: 0,
|
||||||
|
is_webhook: 0,
|
||||||
|
is_versioned: 0,
|
||||||
|
version: '1',
|
||||||
|
properties_schema: JSON.stringify([]),
|
||||||
|
operations: JSON.stringify([]),
|
||||||
|
credentials_required: JSON.stringify([]),
|
||||||
|
documentation: null,
|
||||||
|
outputs: '{invalid json}',
|
||||||
|
output_names: '[invalid, json'
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStatement.get.mockReturnValue(mockRow);
|
||||||
|
|
||||||
|
const result = repository.getNode('nodes-base.malformed');
|
||||||
|
|
||||||
|
// Should use default values when JSON parsing fails
|
||||||
|
expect(result.outputs).toBe(null);
|
||||||
|
expect(result.outputNames).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for non-existent node', () => {
|
||||||
|
mockStatement.get.mockReturnValue(null);
|
||||||
|
|
||||||
|
const result = repository.getNode('nodes-base.nonExistent');
|
||||||
|
|
||||||
|
expect(result).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle SplitInBatches counterintuitive output order correctly', () => {
|
||||||
|
// Test that the output order is preserved: done=0, loop=1
|
||||||
|
const outputs = [
|
||||||
|
{ displayName: 'Done', description: 'Final results when loop completes', index: 0 },
|
||||||
|
{ displayName: 'Loop', description: 'Current batch data during iteration', index: 1 }
|
||||||
|
];
|
||||||
|
const outputNames = ['done', 'loop'];
|
||||||
|
|
||||||
|
const mockRow = {
|
||||||
|
node_type: 'nodes-base.splitInBatches',
|
||||||
|
display_name: 'Split In Batches',
|
||||||
|
description: 'Split data into batches',
|
||||||
|
category: 'transform',
|
||||||
|
development_style: 'programmatic',
|
||||||
|
package_name: 'n8n-nodes-base',
|
||||||
|
is_ai_tool: 0,
|
||||||
|
is_trigger: 0,
|
||||||
|
is_webhook: 0,
|
||||||
|
is_versioned: 0,
|
||||||
|
version: '3',
|
||||||
|
properties_schema: JSON.stringify([]),
|
||||||
|
operations: JSON.stringify([]),
|
||||||
|
credentials_required: JSON.stringify([]),
|
||||||
|
documentation: null,
|
||||||
|
outputs: JSON.stringify(outputs),
|
||||||
|
output_names: JSON.stringify(outputNames)
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStatement.get.mockReturnValue(mockRow);
|
||||||
|
|
||||||
|
const result = repository.getNode('nodes-base.splitInBatches');
|
||||||
|
|
||||||
|
// Verify order is preserved
|
||||||
|
expect(result.outputs[0].displayName).toBe('Done');
|
||||||
|
expect(result.outputs[1].displayName).toBe('Loop');
|
||||||
|
expect(result.outputNames[0]).toBe('done');
|
||||||
|
expect(result.outputNames[1]).toBe('loop');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseNodeRow with outputs', () => {
|
||||||
|
it('should parse node row with outputs correctly using parseNodeRow', () => {
|
||||||
|
const outputs = [{ displayName: 'Output' }];
|
||||||
|
const outputNames = ['main'];
|
||||||
|
|
||||||
|
const mockRow = {
|
||||||
|
node_type: 'nodes-base.test',
|
||||||
|
display_name: 'Test',
|
||||||
|
description: 'Test node',
|
||||||
|
category: 'misc',
|
||||||
|
development_style: 'programmatic',
|
||||||
|
package_name: 'n8n-nodes-base',
|
||||||
|
is_ai_tool: 0,
|
||||||
|
is_trigger: 0,
|
||||||
|
is_webhook: 0,
|
||||||
|
is_versioned: 0,
|
||||||
|
version: '1',
|
||||||
|
properties_schema: JSON.stringify([]),
|
||||||
|
operations: JSON.stringify([]),
|
||||||
|
credentials_required: JSON.stringify([]),
|
||||||
|
documentation: null,
|
||||||
|
outputs: JSON.stringify(outputs),
|
||||||
|
output_names: JSON.stringify(outputNames)
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStatement.all.mockReturnValue([mockRow]);
|
||||||
|
|
||||||
|
const results = repository.getAllNodes(1);
|
||||||
|
|
||||||
|
expect(results[0].outputs).toEqual(outputs);
|
||||||
|
expect(results[0].outputNames).toEqual(outputNames);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string as null for outputs', () => {
|
||||||
|
const mockRow = {
|
||||||
|
node_type: 'nodes-base.empty',
|
||||||
|
display_name: 'Empty',
|
||||||
|
description: 'Empty node',
|
||||||
|
category: 'misc',
|
||||||
|
development_style: 'programmatic',
|
||||||
|
package_name: 'n8n-nodes-base',
|
||||||
|
is_ai_tool: 0,
|
||||||
|
is_trigger: 0,
|
||||||
|
is_webhook: 0,
|
||||||
|
is_versioned: 0,
|
||||||
|
version: '1',
|
||||||
|
properties_schema: JSON.stringify([]),
|
||||||
|
operations: JSON.stringify([]),
|
||||||
|
credentials_required: JSON.stringify([]),
|
||||||
|
documentation: null,
|
||||||
|
outputs: '', // empty string
|
||||||
|
output_names: '' // empty string
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStatement.all.mockReturnValue([mockRow]);
|
||||||
|
|
||||||
|
const results = repository.getAllNodes(1);
|
||||||
|
|
||||||
|
// Empty strings should be treated as null since they fail JSON parsing
|
||||||
|
expect(results[0].outputs).toBe(null);
|
||||||
|
expect(results[0].outputNames).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('complex output structures', () => {
|
||||||
|
it('should handle complex output objects with metadata', () => {
|
||||||
|
const complexOutputs = [
|
||||||
|
{
|
||||||
|
displayName: 'Done',
|
||||||
|
name: 'done',
|
||||||
|
type: 'main',
|
||||||
|
hint: 'Receives the final data after all batches have been processed',
|
||||||
|
description: 'Final results when loop completes',
|
||||||
|
index: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Loop',
|
||||||
|
name: 'loop',
|
||||||
|
type: 'main',
|
||||||
|
hint: 'Receives the current batch data during each iteration',
|
||||||
|
description: 'Current batch data during iteration',
|
||||||
|
index: 1
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const node: ParsedNode = {
|
||||||
|
style: 'programmatic',
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
displayName: 'Split In Batches',
|
||||||
|
description: 'Split data into batches',
|
||||||
|
category: 'transform',
|
||||||
|
properties: [],
|
||||||
|
credentials: [],
|
||||||
|
isAITool: false,
|
||||||
|
isTrigger: false,
|
||||||
|
isWebhook: false,
|
||||||
|
operations: [],
|
||||||
|
version: '3',
|
||||||
|
isVersioned: false,
|
||||||
|
packageName: 'n8n-nodes-base',
|
||||||
|
outputs: complexOutputs,
|
||||||
|
outputNames: ['done', 'loop']
|
||||||
|
};
|
||||||
|
|
||||||
|
repository.saveNode(node);
|
||||||
|
|
||||||
|
// Simulate retrieval
|
||||||
|
const mockRow = {
|
||||||
|
node_type: 'nodes-base.splitInBatches',
|
||||||
|
display_name: 'Split In Batches',
|
||||||
|
description: 'Split data into batches',
|
||||||
|
category: 'transform',
|
||||||
|
development_style: 'programmatic',
|
||||||
|
package_name: 'n8n-nodes-base',
|
||||||
|
is_ai_tool: 0,
|
||||||
|
is_trigger: 0,
|
||||||
|
is_webhook: 0,
|
||||||
|
is_versioned: 0,
|
||||||
|
version: '3',
|
||||||
|
properties_schema: JSON.stringify([]),
|
||||||
|
operations: JSON.stringify([]),
|
||||||
|
credentials_required: JSON.stringify([]),
|
||||||
|
documentation: null,
|
||||||
|
outputs: JSON.stringify(complexOutputs),
|
||||||
|
output_names: JSON.stringify(['done', 'loop'])
|
||||||
|
};
|
||||||
|
|
||||||
|
mockStatement.get.mockReturnValue(mockRow);
|
||||||
|
|
||||||
|
const result = repository.getNode('nodes-base.splitInBatches');
|
||||||
|
|
||||||
|
expect(result.outputs).toEqual(complexOutputs);
|
||||||
|
expect(result.outputs[0]).toMatchObject({
|
||||||
|
displayName: 'Done',
|
||||||
|
name: 'done',
|
||||||
|
type: 'main',
|
||||||
|
hint: 'Receives the final data after all batches have been processed'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -299,6 +299,268 @@ describe('DocsMapper', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('enhanceLoopNodeDocumentation - SplitInBatches', () => {
|
||||||
|
it('should enhance SplitInBatches documentation with output guidance', async () => {
|
||||||
|
const originalContent = `# Split In Batches Node
|
||||||
|
|
||||||
|
This node splits data into batches.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
Use this node when you need to process large datasets in smaller chunks.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
- batchSize: Number of items per batch
|
||||||
|
`;
|
||||||
|
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValueOnce(originalContent);
|
||||||
|
|
||||||
|
const result = await docsMapper.fetchDocumentation('splitInBatches');
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!).toContain('CRITICAL OUTPUT CONNECTION INFORMATION');
|
||||||
|
expect(result!).toContain('⚠️ OUTPUT INDICES ARE COUNTERINTUITIVE ⚠️');
|
||||||
|
expect(result!).toContain('Output 0 (index 0) = "done"');
|
||||||
|
expect(result!).toContain('Output 1 (index 1) = "loop"');
|
||||||
|
expect(result!).toContain('Correct Connection Pattern:');
|
||||||
|
expect(result!).toContain('Common Mistake:');
|
||||||
|
expect(result!).toContain('AI assistants often connect these backwards');
|
||||||
|
|
||||||
|
// Should insert before "When to use" section
|
||||||
|
const insertionIndex = result!.indexOf('## When to use');
|
||||||
|
const guidanceIndex = result!.indexOf('CRITICAL OUTPUT CONNECTION INFORMATION');
|
||||||
|
expect(guidanceIndex).toBeLessThan(insertionIndex);
|
||||||
|
expect(guidanceIndex).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enhance SplitInBatches documentation when no "When to use" section exists', async () => {
|
||||||
|
const originalContent = `# Split In Batches Node
|
||||||
|
|
||||||
|
This node splits data into batches.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
- batchSize: Number of items per batch
|
||||||
|
`;
|
||||||
|
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValueOnce(originalContent);
|
||||||
|
|
||||||
|
const result = await docsMapper.fetchDocumentation('splitInBatches');
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!).toContain('CRITICAL OUTPUT CONNECTION INFORMATION');
|
||||||
|
// Should be inserted at the beginning since no "When to use" section
|
||||||
|
expect(result!.indexOf('CRITICAL OUTPUT CONNECTION INFORMATION')).toBeLessThan(
|
||||||
|
result!.indexOf('# Split In Batches Node')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle splitInBatches in various node type formats', async () => {
|
||||||
|
const testCases = [
|
||||||
|
'splitInBatches',
|
||||||
|
'n8n-nodes-base.splitInBatches',
|
||||||
|
'nodes-base.splitInBatches'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const nodeType of testCases) {
|
||||||
|
const originalContent = '# Split In Batches\nOriginal content';
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValueOnce(originalContent);
|
||||||
|
|
||||||
|
const result = await docsMapper.fetchDocumentation(nodeType);
|
||||||
|
|
||||||
|
expect(result).toContain('CRITICAL OUTPUT CONNECTION INFORMATION');
|
||||||
|
expect(result).toContain('Output 0 (index 0) = "done"');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should provide specific guidance for correct connection patterns', async () => {
|
||||||
|
const originalContent = '# Split In Batches\n## When to use\nContent';
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValueOnce(originalContent);
|
||||||
|
|
||||||
|
const result = await docsMapper.fetchDocumentation('splitInBatches');
|
||||||
|
|
||||||
|
expect(result).toContain('Connect nodes that PROCESS items inside the loop to **Output 1 ("loop")**');
|
||||||
|
expect(result).toContain('Connect nodes that run AFTER the loop completes to **Output 0 ("done")**');
|
||||||
|
expect(result).toContain('The last processing node in the loop must connect back to the SplitInBatches node');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should explain the common AI assistant mistake', async () => {
|
||||||
|
const originalContent = '# Split In Batches\n## When to use\nContent';
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValueOnce(originalContent);
|
||||||
|
|
||||||
|
const result = await docsMapper.fetchDocumentation('splitInBatches');
|
||||||
|
|
||||||
|
expect(result).toContain('AI assistants often connect these backwards');
|
||||||
|
expect(result).toContain('logical flow (loop first, then done) doesn\'t match the technical indices (done=0, loop=1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not enhance non-splitInBatches nodes with loop guidance', async () => {
|
||||||
|
const originalContent = '# HTTP Request Node\nContent';
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValueOnce(originalContent);
|
||||||
|
|
||||||
|
const result = await docsMapper.fetchDocumentation('httpRequest');
|
||||||
|
|
||||||
|
expect(result).not.toContain('CRITICAL OUTPUT CONNECTION INFORMATION');
|
||||||
|
expect(result).not.toContain('counterintuitive');
|
||||||
|
expect(result).toBe(originalContent); // Should be unchanged
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enhanceLoopNodeDocumentation - IF node', () => {
|
||||||
|
it('should enhance IF node documentation with output guidance', async () => {
|
||||||
|
const originalContent = `# IF Node
|
||||||
|
|
||||||
|
Route items based on conditions.
|
||||||
|
|
||||||
|
## Node parameters
|
||||||
|
|
||||||
|
Configure your conditions here.
|
||||||
|
`;
|
||||||
|
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValueOnce(originalContent);
|
||||||
|
|
||||||
|
const result = await docsMapper.fetchDocumentation('n8n-nodes-base.if');
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!).toContain('Output Connection Information');
|
||||||
|
expect(result!).toContain('Output 0 (index 0) = "true"');
|
||||||
|
expect(result!).toContain('Output 1 (index 1) = "false"');
|
||||||
|
expect(result!).toContain('Items that match the condition');
|
||||||
|
expect(result!).toContain('Items that do not match the condition');
|
||||||
|
|
||||||
|
// Should insert before "Node parameters" section
|
||||||
|
const parametersIndex = result!.indexOf('## Node parameters');
|
||||||
|
const outputInfoIndex = result!.indexOf('Output Connection Information');
|
||||||
|
expect(outputInfoIndex).toBeLessThan(parametersIndex);
|
||||||
|
expect(outputInfoIndex).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle IF node when no "Node parameters" section exists', async () => {
|
||||||
|
const originalContent = `# IF Node
|
||||||
|
|
||||||
|
Route items based on conditions.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Use this node to route data.
|
||||||
|
`;
|
||||||
|
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValueOnce(originalContent);
|
||||||
|
|
||||||
|
const result = await docsMapper.fetchDocumentation('n8n-nodes-base.if');
|
||||||
|
|
||||||
|
// When no "Node parameters" section exists, no enhancement is applied
|
||||||
|
expect(result).toBe(originalContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle various IF node type formats', async () => {
|
||||||
|
const testCases = [
|
||||||
|
'if',
|
||||||
|
'n8n-nodes-base.if',
|
||||||
|
'nodes-base.if'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const nodeType of testCases) {
|
||||||
|
const originalContent = '# IF Node\n## Node parameters\nContent';
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValueOnce(originalContent);
|
||||||
|
|
||||||
|
const result = await docsMapper.fetchDocumentation(nodeType);
|
||||||
|
|
||||||
|
if (nodeType.includes('.if')) {
|
||||||
|
expect(result).toContain('Output Connection Information');
|
||||||
|
expect(result).toContain('Output 0 (index 0) = "true"');
|
||||||
|
expect(result).toContain('Output 1 (index 1) = "false"');
|
||||||
|
} else {
|
||||||
|
// For 'if' without dot, no enhancement is applied
|
||||||
|
expect(result).toBe(originalContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enhanceLoopNodeDocumentation - edge cases', () => {
|
||||||
|
it('should handle content without clear insertion points', async () => {
|
||||||
|
const originalContent = 'Simple content without markdown sections';
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValueOnce(originalContent);
|
||||||
|
|
||||||
|
const result = await docsMapper.fetchDocumentation('splitInBatches');
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!).toContain('CRITICAL OUTPUT CONNECTION INFORMATION');
|
||||||
|
// Should be prepended when no insertion point found (but there's a newline before original content)
|
||||||
|
const guidanceIndex = result!.indexOf('CRITICAL OUTPUT CONNECTION INFORMATION');
|
||||||
|
expect(guidanceIndex).toBeLessThan(result!.indexOf('Simple content'));
|
||||||
|
expect(guidanceIndex).toBeLessThanOrEqual(5); // Allow for some whitespace
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty content', async () => {
|
||||||
|
const originalContent = '';
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValueOnce(originalContent);
|
||||||
|
|
||||||
|
const result = await docsMapper.fetchDocumentation('splitInBatches');
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!).toContain('CRITICAL OUTPUT CONNECTION INFORMATION');
|
||||||
|
expect(result!.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle content with multiple "When to use" sections', async () => {
|
||||||
|
const originalContent = `# Split In Batches
|
||||||
|
|
||||||
|
## When to use (overview)
|
||||||
|
|
||||||
|
General usage.
|
||||||
|
|
||||||
|
## When to use (detailed)
|
||||||
|
|
||||||
|
Detailed usage.
|
||||||
|
`;
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValueOnce(originalContent);
|
||||||
|
|
||||||
|
const result = await docsMapper.fetchDocumentation('splitInBatches');
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!).toContain('CRITICAL OUTPUT CONNECTION INFORMATION');
|
||||||
|
// Should insert before first occurrence
|
||||||
|
const firstWhenToUse = result!.indexOf('## When to use (overview)');
|
||||||
|
const guidanceIndex = result!.indexOf('CRITICAL OUTPUT CONNECTION INFORMATION');
|
||||||
|
expect(guidanceIndex).toBeLessThan(firstWhenToUse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not double-enhance already enhanced content', async () => {
|
||||||
|
const alreadyEnhancedContent = `# Split In Batches
|
||||||
|
|
||||||
|
## CRITICAL OUTPUT CONNECTION INFORMATION
|
||||||
|
|
||||||
|
Already enhanced.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
Content here.
|
||||||
|
`;
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValueOnce(alreadyEnhancedContent);
|
||||||
|
|
||||||
|
const result = await docsMapper.fetchDocumentation('splitInBatches');
|
||||||
|
|
||||||
|
// Should still add enhancement (method doesn't check for existing enhancements)
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
const criticalSections = (result!.match(/CRITICAL OUTPUT CONNECTION INFORMATION/g) || []).length;
|
||||||
|
expect(criticalSections).toBe(2); // Original + new enhancement
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very large content efficiently', async () => {
|
||||||
|
const largeContent = 'a'.repeat(100000) + '\n## When to use\n' + 'b'.repeat(100000);
|
||||||
|
vi.mocked(fs.readFile).mockResolvedValueOnce(largeContent);
|
||||||
|
|
||||||
|
const result = await docsMapper.fetchDocumentation('splitInBatches');
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!).toContain('CRITICAL OUTPUT CONNECTION INFORMATION');
|
||||||
|
expect(result!.length).toBeGreaterThan(largeContent.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('DocsMapper instance', () => {
|
describe('DocsMapper instance', () => {
|
||||||
it('should use consistent docsPath across instances', () => {
|
it('should use consistent docsPath across instances', () => {
|
||||||
const mapper1 = new DocsMapper();
|
const mapper1 = new DocsMapper();
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ describe('Parameter Validation', () => {
|
|||||||
describe('search_nodes', () => {
|
describe('search_nodes', () => {
|
||||||
it('should require query parameter', async () => {
|
it('should require query parameter', async () => {
|
||||||
await expect(server.testExecuteTool('search_nodes', {}))
|
await expect(server.testExecuteTool('search_nodes', {}))
|
||||||
.rejects.toThrow('Missing required parameters for search_nodes: query');
|
.rejects.toThrow('search_nodes: Validation failed:\n • query: query is required');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should succeed with valid query', async () => {
|
it('should succeed with valid query', async () => {
|
||||||
@@ -194,29 +194,28 @@ describe('Parameter Validation', () => {
|
|||||||
expect(result).toEqual({ results: [] });
|
expect(result).toEqual({ results: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should convert limit to number and use default on invalid value', async () => {
|
it('should reject invalid limit value', async () => {
|
||||||
const result = await server.testExecuteTool('search_nodes', {
|
await expect(server.testExecuteTool('search_nodes', {
|
||||||
query: 'http',
|
query: 'http',
|
||||||
limit: 'invalid'
|
limit: 'invalid'
|
||||||
});
|
})).rejects.toThrow('search_nodes: Validation failed:\n • limit: limit must be a number, got string');
|
||||||
expect(result).toEqual({ results: [] });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('validate_node_operation', () => {
|
describe('validate_node_operation', () => {
|
||||||
it('should require nodeType and config parameters', async () => {
|
it('should require nodeType and config parameters', async () => {
|
||||||
await expect(server.testExecuteTool('validate_node_operation', {}))
|
await expect(server.testExecuteTool('validate_node_operation', {}))
|
||||||
.rejects.toThrow('Missing required parameters for validate_node_operation: nodeType, config');
|
.rejects.toThrow('validate_node_operation: Validation failed:\n • nodeType: nodeType is required\n • config: config is required');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should require nodeType parameter when config is provided', async () => {
|
it('should require nodeType parameter when config is provided', async () => {
|
||||||
await expect(server.testExecuteTool('validate_node_operation', { config: {} }))
|
await expect(server.testExecuteTool('validate_node_operation', { config: {} }))
|
||||||
.rejects.toThrow('Missing required parameters for validate_node_operation: nodeType');
|
.rejects.toThrow('validate_node_operation: Validation failed:\n • nodeType: nodeType is required');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should require config parameter when nodeType is provided', async () => {
|
it('should require config parameter when nodeType is provided', async () => {
|
||||||
await expect(server.testExecuteTool('validate_node_operation', { nodeType: 'nodes-base.httpRequest' }))
|
await expect(server.testExecuteTool('validate_node_operation', { nodeType: 'nodes-base.httpRequest' }))
|
||||||
.rejects.toThrow('Missing required parameters for validate_node_operation: config');
|
.rejects.toThrow('validate_node_operation: Validation failed:\n • config: config is required');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should succeed with valid parameters', async () => {
|
it('should succeed with valid parameters', async () => {
|
||||||
@@ -255,7 +254,7 @@ describe('Parameter Validation', () => {
|
|||||||
describe('list_node_templates', () => {
|
describe('list_node_templates', () => {
|
||||||
it('should require nodeTypes parameter', async () => {
|
it('should require nodeTypes parameter', async () => {
|
||||||
await expect(server.testExecuteTool('list_node_templates', {}))
|
await expect(server.testExecuteTool('list_node_templates', {}))
|
||||||
.rejects.toThrow('Missing required parameters for list_node_templates: nodeTypes');
|
.rejects.toThrow('list_node_templates: Validation failed:\n • nodeTypes: nodeTypes is required');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should succeed with valid nodeTypes array', async () => {
|
it('should succeed with valid nodeTypes array', async () => {
|
||||||
@@ -290,26 +289,18 @@ describe('Parameter Validation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('limit parameter conversion', () => {
|
describe('limit parameter conversion', () => {
|
||||||
it('should convert string numbers to numbers', async () => {
|
it('should reject string limit values', async () => {
|
||||||
const mockSearchNodes = vi.spyOn(server as any, 'searchNodes');
|
await expect(server.testExecuteTool('search_nodes', {
|
||||||
|
|
||||||
await server.testExecuteTool('search_nodes', {
|
|
||||||
query: 'test',
|
query: 'test',
|
||||||
limit: '15'
|
limit: '15'
|
||||||
|
})).rejects.toThrow('search_nodes: Validation failed:\n • limit: limit must be a number, got string');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockSearchNodes).toHaveBeenCalledWith('test', 15, { mode: undefined });
|
it('should reject invalid string limit values', async () => {
|
||||||
});
|
await expect(server.testExecuteTool('search_nodes', {
|
||||||
|
|
||||||
it('should use default when limit is invalid string', async () => {
|
|
||||||
const mockSearchNodes = vi.spyOn(server as any, 'searchNodes');
|
|
||||||
|
|
||||||
await server.testExecuteTool('search_nodes', {
|
|
||||||
query: 'test',
|
query: 'test',
|
||||||
limit: 'invalid'
|
limit: 'invalid'
|
||||||
});
|
})).rejects.toThrow('search_nodes: Validation failed:\n • limit: limit must be a number, got string');
|
||||||
|
|
||||||
expect(mockSearchNodes).toHaveBeenCalledWith('test', 20, { mode: undefined });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use default when limit is undefined', async () => {
|
it('should use default when limit is undefined', async () => {
|
||||||
@@ -322,15 +313,11 @@ describe('Parameter Validation', () => {
|
|||||||
expect(mockSearchNodes).toHaveBeenCalledWith('test', 20, { mode: undefined });
|
expect(mockSearchNodes).toHaveBeenCalledWith('test', 20, { mode: undefined });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle zero as valid limit', async () => {
|
it('should reject zero as limit due to minimum constraint', async () => {
|
||||||
const mockSearchNodes = vi.spyOn(server as any, 'searchNodes');
|
await expect(server.testExecuteTool('search_nodes', {
|
||||||
|
|
||||||
await server.testExecuteTool('search_nodes', {
|
|
||||||
query: 'test',
|
query: 'test',
|
||||||
limit: 0
|
limit: 0
|
||||||
});
|
})).rejects.toThrow('search_nodes: Validation failed:\n • limit: limit must be at least 1, got 0');
|
||||||
|
|
||||||
expect(mockSearchNodes).toHaveBeenCalledWith('test', 20, { mode: undefined }); // 0 converts to falsy, uses default
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -361,26 +348,18 @@ describe('Parameter Validation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('templateLimit parameter conversion', () => {
|
describe('templateLimit parameter conversion', () => {
|
||||||
it('should convert string numbers to numbers', async () => {
|
it('should reject string limit values', async () => {
|
||||||
const mockListNodeTemplates = vi.spyOn(server as any, 'listNodeTemplates');
|
await expect(server.testExecuteTool('list_node_templates', {
|
||||||
|
|
||||||
await server.testExecuteTool('list_node_templates', {
|
|
||||||
nodeTypes: ['nodes-base.httpRequest'],
|
nodeTypes: ['nodes-base.httpRequest'],
|
||||||
limit: '5'
|
limit: '5'
|
||||||
|
})).rejects.toThrow('list_node_templates: Validation failed:\n • limit: limit must be a number, got string');
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockListNodeTemplates).toHaveBeenCalledWith(['nodes-base.httpRequest'], 5);
|
it('should reject invalid string limit values', async () => {
|
||||||
});
|
await expect(server.testExecuteTool('list_node_templates', {
|
||||||
|
|
||||||
it('should use default when templateLimit is invalid', async () => {
|
|
||||||
const mockListNodeTemplates = vi.spyOn(server as any, 'listNodeTemplates');
|
|
||||||
|
|
||||||
await server.testExecuteTool('list_node_templates', {
|
|
||||||
nodeTypes: ['nodes-base.httpRequest'],
|
nodeTypes: ['nodes-base.httpRequest'],
|
||||||
limit: 'invalid'
|
limit: 'invalid'
|
||||||
});
|
})).rejects.toThrow('list_node_templates: Validation failed:\n • limit: limit must be a number, got string');
|
||||||
|
|
||||||
expect(mockListNodeTemplates).toHaveBeenCalledWith(['nodes-base.httpRequest'], 10);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -452,7 +431,7 @@ describe('Parameter Validation', () => {
|
|||||||
it('should list all missing parameters', () => {
|
it('should list all missing parameters', () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
server.testValidateToolParams('validate_node_operation', { profile: 'strict' }, ['nodeType', 'config']);
|
server.testValidateToolParams('validate_node_operation', { profile: 'strict' }, ['nodeType', 'config']);
|
||||||
}).toThrow('Missing required parameters for validate_node_operation: nodeType, config');
|
}).toThrow('validate_node_operation: Validation failed:\n • nodeType: nodeType is required\n • config: config is required');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include helpful guidance', () => {
|
it('should include helpful guidance', () => {
|
||||||
@@ -475,10 +454,10 @@ describe('Parameter Validation', () => {
|
|||||||
.rejects.toThrow('Missing required parameters for get_node_info: nodeType');
|
.rejects.toThrow('Missing required parameters for get_node_info: nodeType');
|
||||||
|
|
||||||
await expect(server.testExecuteTool('search_nodes', {}))
|
await expect(server.testExecuteTool('search_nodes', {}))
|
||||||
.rejects.toThrow('Missing required parameters for search_nodes: query');
|
.rejects.toThrow('search_nodes: Validation failed:\n • query: query is required');
|
||||||
|
|
||||||
await expect(server.testExecuteTool('validate_node_operation', { nodeType: 'test' }))
|
await expect(server.testExecuteTool('validate_node_operation', { nodeType: 'test' }))
|
||||||
.rejects.toThrow('Missing required parameters for validate_node_operation: config');
|
.rejects.toThrow('validate_node_operation: Validation failed:\n • config: config is required');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle edge cases in parameter validation gracefully', async () => {
|
it('should handle edge cases in parameter validation gracefully', async () => {
|
||||||
@@ -492,24 +471,34 @@ describe('Parameter Validation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should provide consistent error format across all tools', async () => {
|
it('should provide consistent error format across all tools', async () => {
|
||||||
const toolsWithRequiredParams = [
|
// Tools using legacy validation
|
||||||
{ name: 'get_node_info', args: {}, missing: 'nodeType' },
|
const legacyValidationTools = [
|
||||||
{ name: 'search_nodes', args: {}, missing: 'query' },
|
{ name: 'get_node_info', args: {}, expected: 'Missing required parameters for get_node_info: nodeType' },
|
||||||
{ name: 'get_node_documentation', args: {}, missing: 'nodeType' },
|
{ name: 'get_node_documentation', args: {}, expected: 'Missing required parameters for get_node_documentation: nodeType' },
|
||||||
{ name: 'get_node_essentials', args: {}, missing: 'nodeType' },
|
{ name: 'get_node_essentials', args: {}, expected: 'Missing required parameters for get_node_essentials: nodeType' },
|
||||||
{ name: 'search_node_properties', args: {}, missing: 'nodeType, query' },
|
{ name: 'search_node_properties', args: {}, expected: 'Missing required parameters for search_node_properties: nodeType, query' },
|
||||||
{ name: 'get_node_for_task', args: {}, missing: 'task' },
|
{ name: 'get_node_for_task', args: {}, expected: 'Missing required parameters for get_node_for_task: task' },
|
||||||
{ name: 'validate_node_operation', args: {}, missing: 'nodeType, config' },
|
{ name: 'get_property_dependencies', args: {}, expected: 'Missing required parameters for get_property_dependencies: nodeType' },
|
||||||
{ name: 'validate_node_minimal', args: {}, missing: 'nodeType, config' },
|
{ name: 'get_node_as_tool_info', args: {}, expected: 'Missing required parameters for get_node_as_tool_info: nodeType' },
|
||||||
{ name: 'get_property_dependencies', args: {}, missing: 'nodeType' },
|
{ name: 'get_template', args: {}, expected: 'Missing required parameters for get_template: templateId' },
|
||||||
{ name: 'get_node_as_tool_info', args: {}, missing: 'nodeType' },
|
|
||||||
{ name: 'list_node_templates', args: {}, missing: 'nodeTypes' },
|
|
||||||
{ name: 'get_template', args: {}, missing: 'templateId' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const tool of toolsWithRequiredParams) {
|
for (const tool of legacyValidationTools) {
|
||||||
await expect(server.testExecuteTool(tool.name, tool.args))
|
await expect(server.testExecuteTool(tool.name, tool.args))
|
||||||
.rejects.toThrow(`Missing required parameters for ${tool.name}: ${tool.missing}`);
|
.rejects.toThrow(tool.expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools using new schema validation
|
||||||
|
const schemaValidationTools = [
|
||||||
|
{ name: 'search_nodes', args: {}, expected: 'search_nodes: Validation failed:\n • query: query is required' },
|
||||||
|
{ name: 'validate_node_operation', args: {}, expected: 'validate_node_operation: Validation failed:\n • nodeType: nodeType is required\n • config: config is required' },
|
||||||
|
{ name: 'validate_node_minimal', args: {}, expected: 'validate_node_minimal: Validation failed:\n • nodeType: nodeType is required\n • config: config is required' },
|
||||||
|
{ name: 'list_node_templates', args: {}, expected: 'list_node_templates: Validation failed:\n • nodeTypes: nodeTypes is required' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const tool of schemaValidationTools) {
|
||||||
|
await expect(server.testExecuteTool(tool.name, tool.args))
|
||||||
|
.rejects.toThrow(tool.expected);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -540,23 +529,28 @@ describe('Parameter Validation', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const n8nToolsWithRequiredParams = [
|
const n8nToolsWithRequiredParams = [
|
||||||
{ name: 'n8n_create_workflow', args: {}, missing: 'name, nodes, connections' },
|
{ name: 'n8n_create_workflow', args: {}, expected: 'n8n_create_workflow: Validation failed:\n • name: name is required\n • nodes: nodes is required\n • connections: connections is required' },
|
||||||
{ name: 'n8n_get_workflow', args: {}, missing: 'id' },
|
{ name: 'n8n_get_workflow', args: {}, expected: 'n8n_get_workflow: Validation failed:\n • id: id is required' },
|
||||||
{ name: 'n8n_get_workflow_details', args: {}, missing: 'id' },
|
{ name: 'n8n_get_workflow_details', args: {}, expected: 'n8n_get_workflow_details: Validation failed:\n • id: id is required' },
|
||||||
{ name: 'n8n_get_workflow_structure', args: {}, missing: 'id' },
|
{ name: 'n8n_get_workflow_structure', args: {}, expected: 'n8n_get_workflow_structure: Validation failed:\n • id: id is required' },
|
||||||
{ name: 'n8n_get_workflow_minimal', args: {}, missing: 'id' },
|
{ name: 'n8n_get_workflow_minimal', args: {}, expected: 'n8n_get_workflow_minimal: Validation failed:\n • id: id is required' },
|
||||||
{ name: 'n8n_update_full_workflow', args: {}, missing: 'id' },
|
{ name: 'n8n_update_full_workflow', args: {}, expected: 'n8n_update_full_workflow: Validation failed:\n • id: id is required' },
|
||||||
{ name: 'n8n_update_partial_workflow', args: {}, missing: 'id, operations' },
|
{ name: 'n8n_delete_workflow', args: {}, expected: 'n8n_delete_workflow: Validation failed:\n • id: id is required' },
|
||||||
{ name: 'n8n_delete_workflow', args: {}, missing: 'id' },
|
{ name: 'n8n_validate_workflow', args: {}, expected: 'n8n_validate_workflow: Validation failed:\n • id: id is required' },
|
||||||
{ name: 'n8n_validate_workflow', args: {}, missing: 'id' },
|
{ name: 'n8n_get_execution', args: {}, expected: 'n8n_get_execution: Validation failed:\n • id: id is required' },
|
||||||
{ name: 'n8n_trigger_webhook_workflow', args: {}, missing: 'webhookUrl' },
|
{ name: 'n8n_delete_execution', args: {}, expected: 'n8n_delete_execution: Validation failed:\n • id: id is required' },
|
||||||
{ name: 'n8n_get_execution', args: {}, missing: 'id' },
|
|
||||||
{ name: 'n8n_delete_execution', args: {}, missing: 'id' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// n8n_update_partial_workflow and n8n_trigger_webhook_workflow use legacy validation
|
||||||
|
await expect(server.testExecuteTool('n8n_update_partial_workflow', {}))
|
||||||
|
.rejects.toThrow('Missing required parameters for n8n_update_partial_workflow: id, operations');
|
||||||
|
|
||||||
|
await expect(server.testExecuteTool('n8n_trigger_webhook_workflow', {}))
|
||||||
|
.rejects.toThrow('Missing required parameters for n8n_trigger_webhook_workflow: webhookUrl');
|
||||||
|
|
||||||
for (const tool of n8nToolsWithRequiredParams) {
|
for (const tool of n8nToolsWithRequiredParams) {
|
||||||
await expect(server.testExecuteTool(tool.name, tool.args))
|
await expect(server.testExecuteTool(tool.name, tool.args))
|
||||||
.rejects.toThrow(`Missing required parameters for ${tool.name}: ${tool.missing}`);
|
.rejects.toThrow(tool.expected);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
473
tests/unit/parsers/node-parser-outputs.test.ts
Normal file
473
tests/unit/parsers/node-parser-outputs.test.ts
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { NodeParser } from '@/parsers/node-parser';
|
||||||
|
import { PropertyExtractor } from '@/parsers/property-extractor';
|
||||||
|
|
||||||
|
// Mock PropertyExtractor
|
||||||
|
vi.mock('@/parsers/property-extractor');
|
||||||
|
|
||||||
|
describe('NodeParser - Output Extraction', () => {
|
||||||
|
let parser: NodeParser;
|
||||||
|
let mockPropertyExtractor: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockPropertyExtractor = {
|
||||||
|
extractProperties: vi.fn().mockReturnValue([]),
|
||||||
|
extractCredentials: vi.fn().mockReturnValue([]),
|
||||||
|
detectAIToolCapability: vi.fn().mockReturnValue(false),
|
||||||
|
extractOperations: vi.fn().mockReturnValue([])
|
||||||
|
};
|
||||||
|
|
||||||
|
(PropertyExtractor as any).mockImplementation(() => mockPropertyExtractor);
|
||||||
|
|
||||||
|
parser = new NodeParser();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractOutputs method', () => {
|
||||||
|
it('should extract outputs array from base description', () => {
|
||||||
|
const outputs = [
|
||||||
|
{ displayName: 'Done', description: 'Final results when loop completes' },
|
||||||
|
{ displayName: 'Loop', description: 'Current batch data during iteration' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const nodeDescription = {
|
||||||
|
name: 'splitInBatches',
|
||||||
|
displayName: 'Split In Batches',
|
||||||
|
outputs
|
||||||
|
};
|
||||||
|
|
||||||
|
const NodeClass = class {
|
||||||
|
description = nodeDescription;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||||
|
|
||||||
|
expect(result.outputs).toEqual(outputs);
|
||||||
|
expect(result.outputNames).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract outputNames array from base description', () => {
|
||||||
|
const outputNames = ['done', 'loop'];
|
||||||
|
|
||||||
|
const nodeDescription = {
|
||||||
|
name: 'splitInBatches',
|
||||||
|
displayName: 'Split In Batches',
|
||||||
|
outputNames
|
||||||
|
};
|
||||||
|
|
||||||
|
const NodeClass = class {
|
||||||
|
description = nodeDescription;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||||
|
|
||||||
|
expect(result.outputNames).toEqual(outputNames);
|
||||||
|
expect(result.outputs).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract both outputs and outputNames when both are present', () => {
|
||||||
|
const outputs = [
|
||||||
|
{ displayName: 'Done', description: 'Final results when loop completes' },
|
||||||
|
{ displayName: 'Loop', description: 'Current batch data during iteration' }
|
||||||
|
];
|
||||||
|
const outputNames = ['done', 'loop'];
|
||||||
|
|
||||||
|
const nodeDescription = {
|
||||||
|
name: 'splitInBatches',
|
||||||
|
displayName: 'Split In Batches',
|
||||||
|
outputs,
|
||||||
|
outputNames
|
||||||
|
};
|
||||||
|
|
||||||
|
const NodeClass = class {
|
||||||
|
description = nodeDescription;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||||
|
|
||||||
|
expect(result.outputs).toEqual(outputs);
|
||||||
|
expect(result.outputNames).toEqual(outputNames);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert single output to array format', () => {
|
||||||
|
const singleOutput = { displayName: 'Output', description: 'Single output' };
|
||||||
|
|
||||||
|
const nodeDescription = {
|
||||||
|
name: 'singleOutputNode',
|
||||||
|
displayName: 'Single Output Node',
|
||||||
|
outputs: singleOutput
|
||||||
|
};
|
||||||
|
|
||||||
|
const NodeClass = class {
|
||||||
|
description = nodeDescription;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||||
|
|
||||||
|
expect(result.outputs).toEqual([singleOutput]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert single outputName to array format', () => {
|
||||||
|
const nodeDescription = {
|
||||||
|
name: 'singleOutputNode',
|
||||||
|
displayName: 'Single Output Node',
|
||||||
|
outputNames: 'main'
|
||||||
|
};
|
||||||
|
|
||||||
|
const NodeClass = class {
|
||||||
|
description = nodeDescription;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||||
|
|
||||||
|
expect(result.outputNames).toEqual(['main']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract outputs from versioned node when not in base description', () => {
|
||||||
|
const versionedOutputs = [
|
||||||
|
{ displayName: 'True', description: 'Items that match condition' },
|
||||||
|
{ displayName: 'False', description: 'Items that do not match condition' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const NodeClass = class {
|
||||||
|
description = {
|
||||||
|
name: 'if',
|
||||||
|
displayName: 'IF'
|
||||||
|
// No outputs in base description
|
||||||
|
};
|
||||||
|
|
||||||
|
nodeVersions = {
|
||||||
|
1: {
|
||||||
|
description: {
|
||||||
|
outputs: versionedOutputs
|
||||||
|
}
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
description: {
|
||||||
|
outputs: versionedOutputs,
|
||||||
|
outputNames: ['true', 'false']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||||
|
|
||||||
|
// Should get outputs from latest version (2)
|
||||||
|
expect(result.outputs).toEqual(versionedOutputs);
|
||||||
|
expect(result.outputNames).toEqual(['true', 'false']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle node instantiation failure gracefully', () => {
|
||||||
|
const NodeClass = class {
|
||||||
|
// Static description that can be accessed when instantiation fails
|
||||||
|
static description = {
|
||||||
|
name: 'problematic',
|
||||||
|
displayName: 'Problematic Node'
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
throw new Error('Cannot instantiate');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||||
|
|
||||||
|
expect(result.outputs).toBeUndefined();
|
||||||
|
expect(result.outputNames).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty result when no outputs found anywhere', () => {
|
||||||
|
const nodeDescription = {
|
||||||
|
name: 'noOutputs',
|
||||||
|
displayName: 'No Outputs Node'
|
||||||
|
// No outputs or outputNames
|
||||||
|
};
|
||||||
|
|
||||||
|
const NodeClass = class {
|
||||||
|
description = nodeDescription;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||||
|
|
||||||
|
expect(result.outputs).toBeUndefined();
|
||||||
|
expect(result.outputNames).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex versioned node structure', () => {
|
||||||
|
const NodeClass = class VersionedNodeType {
|
||||||
|
baseDescription = {
|
||||||
|
name: 'complexVersioned',
|
||||||
|
displayName: 'Complex Versioned Node',
|
||||||
|
defaultVersion: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
nodeVersions = {
|
||||||
|
1: {
|
||||||
|
description: {
|
||||||
|
outputs: [{ displayName: 'V1 Output' }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
description: {
|
||||||
|
outputs: [
|
||||||
|
{ displayName: 'V2 Output 1' },
|
||||||
|
{ displayName: 'V2 Output 2' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
description: {
|
||||||
|
outputs: [
|
||||||
|
{ displayName: 'V3 True', description: 'True branch' },
|
||||||
|
{ displayName: 'V3 False', description: 'False branch' }
|
||||||
|
],
|
||||||
|
outputNames: ['true', 'false']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||||
|
|
||||||
|
// Should use latest version (3)
|
||||||
|
expect(result.outputs).toEqual([
|
||||||
|
{ displayName: 'V3 True', description: 'True branch' },
|
||||||
|
{ displayName: 'V3 False', description: 'False branch' }
|
||||||
|
]);
|
||||||
|
expect(result.outputNames).toEqual(['true', 'false']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer base description outputs over versioned when both exist', () => {
|
||||||
|
const baseOutputs = [{ displayName: 'Base Output' }];
|
||||||
|
const versionedOutputs = [{ displayName: 'Versioned Output' }];
|
||||||
|
|
||||||
|
const NodeClass = class {
|
||||||
|
description = {
|
||||||
|
name: 'preferBase',
|
||||||
|
displayName: 'Prefer Base',
|
||||||
|
outputs: baseOutputs
|
||||||
|
};
|
||||||
|
|
||||||
|
nodeVersions = {
|
||||||
|
1: {
|
||||||
|
description: {
|
||||||
|
outputs: versionedOutputs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||||
|
|
||||||
|
expect(result.outputs).toEqual(baseOutputs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle IF node with typical output structure', () => {
|
||||||
|
const ifOutputs = [
|
||||||
|
{ displayName: 'True', description: 'Items that match the condition' },
|
||||||
|
{ displayName: 'False', description: 'Items that do not match the condition' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const NodeClass = class {
|
||||||
|
description = {
|
||||||
|
name: 'if',
|
||||||
|
displayName: 'IF',
|
||||||
|
outputs: ifOutputs,
|
||||||
|
outputNames: ['true', 'false']
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||||
|
|
||||||
|
expect(result.outputs).toEqual(ifOutputs);
|
||||||
|
expect(result.outputNames).toEqual(['true', 'false']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle SplitInBatches node with counterintuitive output structure', () => {
|
||||||
|
const splitInBatchesOutputs = [
|
||||||
|
{ displayName: 'Done', description: 'Final results when loop completes' },
|
||||||
|
{ displayName: 'Loop', description: 'Current batch data during iteration' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const NodeClass = class {
|
||||||
|
description = {
|
||||||
|
name: 'splitInBatches',
|
||||||
|
displayName: 'Split In Batches',
|
||||||
|
outputs: splitInBatchesOutputs,
|
||||||
|
outputNames: ['done', 'loop']
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||||
|
|
||||||
|
expect(result.outputs).toEqual(splitInBatchesOutputs);
|
||||||
|
expect(result.outputNames).toEqual(['done', 'loop']);
|
||||||
|
|
||||||
|
// Verify the counterintuitive order: done=0, loop=1
|
||||||
|
expect(result.outputs).toBeDefined();
|
||||||
|
expect(result.outputNames).toBeDefined();
|
||||||
|
expect(result.outputs![0].displayName).toBe('Done');
|
||||||
|
expect(result.outputs![1].displayName).toBe('Loop');
|
||||||
|
expect(result.outputNames![0]).toBe('done');
|
||||||
|
expect(result.outputNames![1]).toBe('loop');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Switch node with multiple outputs', () => {
|
||||||
|
const switchOutputs = [
|
||||||
|
{ displayName: 'Output 1', description: 'First branch' },
|
||||||
|
{ displayName: 'Output 2', description: 'Second branch' },
|
||||||
|
{ displayName: 'Output 3', description: 'Third branch' },
|
||||||
|
{ displayName: 'Fallback', description: 'Default branch when no conditions match' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const NodeClass = class {
|
||||||
|
description = {
|
||||||
|
name: 'switch',
|
||||||
|
displayName: 'Switch',
|
||||||
|
outputs: switchOutputs,
|
||||||
|
outputNames: ['0', '1', '2', 'fallback']
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||||
|
|
||||||
|
expect(result.outputs).toEqual(switchOutputs);
|
||||||
|
expect(result.outputNames).toEqual(['0', '1', '2', 'fallback']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty outputs array', () => {
|
||||||
|
const NodeClass = class {
|
||||||
|
description = {
|
||||||
|
name: 'emptyOutputs',
|
||||||
|
displayName: 'Empty Outputs',
|
||||||
|
outputs: [],
|
||||||
|
outputNames: []
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||||
|
|
||||||
|
expect(result.outputs).toEqual([]);
|
||||||
|
expect(result.outputNames).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mismatched outputs and outputNames arrays', () => {
|
||||||
|
const outputs = [
|
||||||
|
{ displayName: 'Output 1' },
|
||||||
|
{ displayName: 'Output 2' }
|
||||||
|
];
|
||||||
|
const outputNames = ['first', 'second', 'third']; // One extra
|
||||||
|
|
||||||
|
const NodeClass = class {
|
||||||
|
description = {
|
||||||
|
name: 'mismatched',
|
||||||
|
displayName: 'Mismatched Arrays',
|
||||||
|
outputs,
|
||||||
|
outputNames
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||||
|
|
||||||
|
expect(result.outputs).toEqual(outputs);
|
||||||
|
expect(result.outputNames).toEqual(outputNames);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('real-world node structures', () => {
|
||||||
|
it('should handle actual n8n SplitInBatches node structure', () => {
|
||||||
|
// This mimics the actual structure from n8n-nodes-base
|
||||||
|
const NodeClass = class {
|
||||||
|
description = {
|
||||||
|
name: 'splitInBatches',
|
||||||
|
displayName: 'Split In Batches',
|
||||||
|
description: 'Split data into batches and iterate over each batch',
|
||||||
|
icon: 'fa:th-large',
|
||||||
|
group: ['transform'],
|
||||||
|
version: 3,
|
||||||
|
outputs: [
|
||||||
|
{
|
||||||
|
displayName: 'Done',
|
||||||
|
name: 'done',
|
||||||
|
type: 'main',
|
||||||
|
hint: 'Receives the final data after all batches have been processed'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'Loop',
|
||||||
|
name: 'loop',
|
||||||
|
type: 'main',
|
||||||
|
hint: 'Receives the current batch data during each iteration'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
outputNames: ['done', 'loop']
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||||
|
|
||||||
|
expect(result.outputs).toHaveLength(2);
|
||||||
|
expect(result.outputs).toBeDefined();
|
||||||
|
expect(result.outputs![0].displayName).toBe('Done');
|
||||||
|
expect(result.outputs![1].displayName).toBe('Loop');
|
||||||
|
expect(result.outputNames).toEqual(['done', 'loop']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle actual n8n IF node structure', () => {
|
||||||
|
// This mimics the actual structure from n8n-nodes-base
|
||||||
|
const NodeClass = class {
|
||||||
|
description = {
|
||||||
|
name: 'if',
|
||||||
|
displayName: 'IF',
|
||||||
|
description: 'Route items to different outputs based on conditions',
|
||||||
|
icon: 'fa:map-signs',
|
||||||
|
group: ['transform'],
|
||||||
|
version: 2,
|
||||||
|
outputs: [
|
||||||
|
{
|
||||||
|
displayName: 'True',
|
||||||
|
name: 'true',
|
||||||
|
type: 'main',
|
||||||
|
hint: 'Items that match the condition'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayName: 'False',
|
||||||
|
name: 'false',
|
||||||
|
type: 'main',
|
||||||
|
hint: 'Items that do not match the condition'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
outputNames: ['true', 'false']
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||||
|
|
||||||
|
expect(result.outputs).toHaveLength(2);
|
||||||
|
expect(result.outputs).toBeDefined();
|
||||||
|
expect(result.outputs![0].displayName).toBe('True');
|
||||||
|
expect(result.outputs![1].displayName).toBe('False');
|
||||||
|
expect(result.outputNames).toEqual(['true', 'false']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single-output nodes like HTTP Request', () => {
|
||||||
|
const NodeClass = class {
|
||||||
|
description = {
|
||||||
|
name: 'httpRequest',
|
||||||
|
displayName: 'HTTP Request',
|
||||||
|
description: 'Make HTTP requests',
|
||||||
|
icon: 'fa:at',
|
||||||
|
group: ['input'],
|
||||||
|
version: 4
|
||||||
|
// No outputs specified - single main output implied
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parser.parse(NodeClass, 'n8n-nodes-base');
|
||||||
|
|
||||||
|
expect(result.outputs).toBeUndefined();
|
||||||
|
expect(result.outputNames).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
450
tests/unit/services/fixed-collection-validation.test.ts
Normal file
450
tests/unit/services/fixed-collection-validation.test.ts
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
/**
|
||||||
|
* Fixed Collection Validation Tests
|
||||||
|
* Tests for the fix of issue #90: "propertyValues[itemName] is not iterable" error
|
||||||
|
*
|
||||||
|
* This ensures AI agents cannot create invalid fixedCollection structures that break n8n UI
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator';
|
||||||
|
|
||||||
|
describe('FixedCollection Validation', () => {
|
||||||
|
describe('Switch Node v2/v3 Validation', () => {
|
||||||
|
test('should detect invalid nested conditions structure', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
rules: {
|
||||||
|
conditions: {
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
value1: '={{$json.status}}',
|
||||||
|
operation: 'equals',
|
||||||
|
value2: 'active'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = EnhancedConfigValidator.validateWithMode(
|
||||||
|
'nodes-base.switch',
|
||||||
|
invalidConfig,
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
'ai-friendly'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
expect(result.errors[0].type).toBe('invalid_value');
|
||||||
|
expect(result.errors[0].property).toBe('rules');
|
||||||
|
expect(result.errors[0].message).toContain('propertyValues[itemName] is not iterable');
|
||||||
|
expect(result.errors[0].fix).toContain('{ "rules": { "values": [{ "conditions": {...}, "outputKey": "output1" }] } }');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should detect direct conditions in rules (another invalid pattern)', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
rules: {
|
||||||
|
conditions: {
|
||||||
|
value1: '={{$json.status}}',
|
||||||
|
operation: 'equals',
|
||||||
|
value2: 'active'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = EnhancedConfigValidator.validateWithMode(
|
||||||
|
'nodes-base.switch',
|
||||||
|
invalidConfig,
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
'ai-friendly'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
expect(result.errors[0].message).toContain('Invalid structure for nodes-base.switch node');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should provide auto-fix for invalid switch structure', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
rules: {
|
||||||
|
conditions: {
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
value1: '={{$json.status}}',
|
||||||
|
operation: 'equals',
|
||||||
|
value2: 'active'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = EnhancedConfigValidator.validateWithMode(
|
||||||
|
'nodes-base.switch',
|
||||||
|
invalidConfig,
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
'ai-friendly'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.autofix).toBeDefined();
|
||||||
|
expect(result.autofix!.rules).toBeDefined();
|
||||||
|
expect(result.autofix!.rules.values).toBeInstanceOf(Array);
|
||||||
|
expect(result.autofix!.rules.values).toHaveLength(1);
|
||||||
|
expect(result.autofix!.rules.values[0]).toHaveProperty('conditions');
|
||||||
|
expect(result.autofix!.rules.values[0]).toHaveProperty('outputKey');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept valid switch structure', () => {
|
||||||
|
const validConfig = {
|
||||||
|
rules: {
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
conditions: {
|
||||||
|
value1: '={{$json.status}}',
|
||||||
|
operation: 'equals',
|
||||||
|
value2: 'active'
|
||||||
|
},
|
||||||
|
outputKey: 'active'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = EnhancedConfigValidator.validateWithMode(
|
||||||
|
'nodes-base.switch',
|
||||||
|
validConfig,
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
'ai-friendly'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should not have the specific fixedCollection error
|
||||||
|
const hasFixedCollectionError = result.errors.some(e =>
|
||||||
|
e.message.includes('propertyValues[itemName] is not iterable')
|
||||||
|
);
|
||||||
|
expect(hasFixedCollectionError).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should warn about missing outputKey in valid structure', () => {
|
||||||
|
const configMissingOutputKey = {
|
||||||
|
rules: {
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
conditions: {
|
||||||
|
value1: '={{$json.status}}',
|
||||||
|
operation: 'equals',
|
||||||
|
value2: 'active'
|
||||||
|
}
|
||||||
|
// Missing outputKey
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = EnhancedConfigValidator.validateWithMode(
|
||||||
|
'nodes-base.switch',
|
||||||
|
configMissingOutputKey,
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
'ai-friendly'
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasOutputKeyWarning = result.warnings.some(w =>
|
||||||
|
w.message.includes('missing "outputKey" property')
|
||||||
|
);
|
||||||
|
expect(hasOutputKeyWarning).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('If Node Validation', () => {
|
||||||
|
test('should detect invalid nested values structure', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
conditions: {
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
value1: '={{$json.age}}',
|
||||||
|
operation: 'largerEqual',
|
||||||
|
value2: 18
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = EnhancedConfigValidator.validateWithMode(
|
||||||
|
'nodes-base.if',
|
||||||
|
invalidConfig,
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
'ai-friendly'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
expect(result.errors[0].type).toBe('invalid_value');
|
||||||
|
expect(result.errors[0].property).toBe('conditions');
|
||||||
|
expect(result.errors[0].message).toContain('Invalid structure for nodes-base.if node');
|
||||||
|
expect(result.errors[0].fix).toBe('Use: { "conditions": {...} } or { "conditions": [...] } directly, not nested under "values"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should provide auto-fix for invalid if structure', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
conditions: {
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
value1: '={{$json.age}}',
|
||||||
|
operation: 'largerEqual',
|
||||||
|
value2: 18
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = EnhancedConfigValidator.validateWithMode(
|
||||||
|
'nodes-base.if',
|
||||||
|
invalidConfig,
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
'ai-friendly'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.autofix).toBeDefined();
|
||||||
|
expect(result.autofix!.conditions).toEqual(invalidConfig.conditions.values);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept valid if structure', () => {
|
||||||
|
const validConfig = {
|
||||||
|
conditions: {
|
||||||
|
value1: '={{$json.age}}',
|
||||||
|
operation: 'largerEqual',
|
||||||
|
value2: 18
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = EnhancedConfigValidator.validateWithMode(
|
||||||
|
'nodes-base.if',
|
||||||
|
validConfig,
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
'ai-friendly'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should not have the specific structure error
|
||||||
|
const hasStructureError = result.errors.some(e =>
|
||||||
|
e.message.includes('should be a filter object/array directly')
|
||||||
|
);
|
||||||
|
expect(hasStructureError).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Filter Node Validation', () => {
|
||||||
|
test('should detect invalid nested values structure', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
conditions: {
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
value1: '={{$json.score}}',
|
||||||
|
operation: 'larger',
|
||||||
|
value2: 80
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = EnhancedConfigValidator.validateWithMode(
|
||||||
|
'nodes-base.filter',
|
||||||
|
invalidConfig,
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
'ai-friendly'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
expect(result.errors[0].type).toBe('invalid_value');
|
||||||
|
expect(result.errors[0].property).toBe('conditions');
|
||||||
|
expect(result.errors[0].message).toContain('Invalid structure for nodes-base.filter node');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept valid filter structure', () => {
|
||||||
|
const validConfig = {
|
||||||
|
conditions: {
|
||||||
|
value1: '={{$json.score}}',
|
||||||
|
operation: 'larger',
|
||||||
|
value2: 80
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = EnhancedConfigValidator.validateWithMode(
|
||||||
|
'nodes-base.filter',
|
||||||
|
validConfig,
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
'ai-friendly'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should not have the specific structure error
|
||||||
|
const hasStructureError = result.errors.some(e =>
|
||||||
|
e.message.includes('should be a filter object/array directly')
|
||||||
|
);
|
||||||
|
expect(hasStructureError).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
test('should not validate non-problematic nodes', () => {
|
||||||
|
const config = {
|
||||||
|
someProperty: {
|
||||||
|
conditions: {
|
||||||
|
values: ['should', 'not', 'trigger', 'validation']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = EnhancedConfigValidator.validateWithMode(
|
||||||
|
'nodes-base.httpRequest',
|
||||||
|
config,
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
'ai-friendly'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should not have fixedCollection errors for non-problematic nodes
|
||||||
|
const hasFixedCollectionError = result.errors.some(e =>
|
||||||
|
e.message.includes('propertyValues[itemName] is not iterable')
|
||||||
|
);
|
||||||
|
expect(hasFixedCollectionError).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty config gracefully', () => {
|
||||||
|
const result = EnhancedConfigValidator.validateWithMode(
|
||||||
|
'nodes-base.switch',
|
||||||
|
{},
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
'ai-friendly'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should not crash or produce false positives
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.errors).toBeInstanceOf(Array);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle non-object property values', () => {
|
||||||
|
const config = {
|
||||||
|
rules: 'not an object'
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = EnhancedConfigValidator.validateWithMode(
|
||||||
|
'nodes-base.switch',
|
||||||
|
config,
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
'ai-friendly'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should not crash on non-object values
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.errors).toBeInstanceOf(Array);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Real-world AI Agent Patterns', () => {
|
||||||
|
test('should catch common ChatGPT/Claude switch patterns', () => {
|
||||||
|
// This is a pattern commonly generated by AI agents
|
||||||
|
const aiGeneratedConfig = {
|
||||||
|
rules: {
|
||||||
|
conditions: {
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
"value1": "={{$json.status}}",
|
||||||
|
"operation": "equals",
|
||||||
|
"value2": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value1": "={{$json.priority}}",
|
||||||
|
"operation": "equals",
|
||||||
|
"value2": "high"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = EnhancedConfigValidator.validateWithMode(
|
||||||
|
'nodes-base.switch',
|
||||||
|
aiGeneratedConfig,
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
'ai-friendly'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
expect(result.errors[0].message).toContain('propertyValues[itemName] is not iterable');
|
||||||
|
|
||||||
|
// Check auto-fix generates correct structure
|
||||||
|
expect(result.autofix!.rules.values).toHaveLength(2);
|
||||||
|
result.autofix!.rules.values.forEach((rule: any) => {
|
||||||
|
expect(rule).toHaveProperty('conditions');
|
||||||
|
expect(rule).toHaveProperty('outputKey');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should catch common AI if/filter patterns', () => {
|
||||||
|
const aiGeneratedIfConfig = {
|
||||||
|
conditions: {
|
||||||
|
values: {
|
||||||
|
"value1": "={{$json.age}}",
|
||||||
|
"operation": "largerEqual",
|
||||||
|
"value2": 21
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = EnhancedConfigValidator.validateWithMode(
|
||||||
|
'nodes-base.if',
|
||||||
|
aiGeneratedIfConfig,
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
'ai-friendly'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors[0].message).toContain('Invalid structure for nodes-base.if node');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Version Compatibility', () => {
|
||||||
|
test('should work across different validation profiles', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
rules: {
|
||||||
|
conditions: {
|
||||||
|
values: [{ value1: 'test', operation: 'equals', value2: 'test' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const profiles: Array<'strict' | 'runtime' | 'ai-friendly' | 'minimal'> =
|
||||||
|
['strict', 'runtime', 'ai-friendly', 'minimal'];
|
||||||
|
|
||||||
|
profiles.forEach(profile => {
|
||||||
|
const result = EnhancedConfigValidator.validateWithMode(
|
||||||
|
'nodes-base.switch',
|
||||||
|
invalidConfig,
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
profile
|
||||||
|
);
|
||||||
|
|
||||||
|
// All profiles should catch this critical error
|
||||||
|
const hasCriticalError = result.errors.some(e =>
|
||||||
|
e.message.includes('propertyValues[itemName] is not iterable')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hasCriticalError, `Profile ${profile} should catch critical fixedCollection error`).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
865
tests/unit/services/loop-output-edge-cases.test.ts
Normal file
865
tests/unit/services/loop-output-edge-cases.test.ts
Normal file
@@ -0,0 +1,865 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { WorkflowValidator } from '@/services/workflow-validator';
|
||||||
|
import { NodeRepository } from '@/database/node-repository';
|
||||||
|
import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@/database/node-repository');
|
||||||
|
vi.mock('@/services/enhanced-config-validator');
|
||||||
|
|
||||||
|
describe('Loop Output Fix - Edge Cases', () => {
|
||||||
|
let validator: WorkflowValidator;
|
||||||
|
let mockNodeRepository: any;
|
||||||
|
let mockNodeValidator: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockNodeRepository = {
|
||||||
|
getNode: vi.fn((nodeType: string) => {
|
||||||
|
// Default return
|
||||||
|
if (nodeType === 'nodes-base.splitInBatches') {
|
||||||
|
return {
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
outputs: [
|
||||||
|
{ displayName: 'Done', name: 'done' },
|
||||||
|
{ displayName: 'Loop', name: 'loop' }
|
||||||
|
],
|
||||||
|
outputNames: ['done', 'loop'],
|
||||||
|
properties: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
nodeType,
|
||||||
|
properties: []
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
mockNodeValidator = {
|
||||||
|
validateWithMode: vi.fn().mockReturnValue({
|
||||||
|
errors: [],
|
||||||
|
warnings: []
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
validator = new WorkflowValidator(mockNodeRepository, mockNodeValidator);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Nodes without outputs', () => {
|
||||||
|
it('should handle nodes with null outputs gracefully', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.httpRequest',
|
||||||
|
outputs: null,
|
||||||
|
outputNames: null,
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'No Outputs Workflow',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'HTTP Request',
|
||||||
|
type: 'n8n-nodes-base.httpRequest',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: { url: 'https://example.com' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Set',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'HTTP Request': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Set', type: 'main', index: 0 }]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should not crash or produce output-related errors
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
const outputErrors = result.errors.filter(e =>
|
||||||
|
e.message?.includes('output') && !e.message?.includes('Connection')
|
||||||
|
);
|
||||||
|
expect(outputErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nodes with undefined outputs gracefully', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.webhook',
|
||||||
|
// outputs and outputNames are undefined
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Undefined Outputs Workflow',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Webhook',
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.valid).toBeTruthy(); // Empty workflow with webhook should be valid
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nodes with empty outputs array', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.customNode',
|
||||||
|
outputs: [],
|
||||||
|
outputNames: [],
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Empty Outputs Workflow',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Custom Node',
|
||||||
|
type: 'n8n-nodes-base.customNode',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Custom Node': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Custom Node', type: 'main', index: 0 }] // Self-reference
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should warn about self-reference but not crash
|
||||||
|
const selfRefWarnings = result.warnings.filter(w =>
|
||||||
|
w.message?.includes('self-referencing')
|
||||||
|
);
|
||||||
|
expect(selfRefWarnings).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Invalid connection indices', () => {
|
||||||
|
it('should handle negative connection indices', async () => {
|
||||||
|
// Use default mock that includes outputs for SplitInBatches
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Negative Index Workflow',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Set',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Set', type: 'main', index: -1 }] // Invalid negative index
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
const negativeIndexErrors = result.errors.filter(e =>
|
||||||
|
e.message?.includes('Invalid connection index -1')
|
||||||
|
);
|
||||||
|
expect(negativeIndexErrors).toHaveLength(1);
|
||||||
|
expect(negativeIndexErrors[0].message).toContain('must be non-negative');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very large connection indices', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.switch',
|
||||||
|
outputs: [
|
||||||
|
{ displayName: 'Output 1' },
|
||||||
|
{ displayName: 'Output 2' }
|
||||||
|
],
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Large Index Workflow',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Switch',
|
||||||
|
type: 'n8n-nodes-base.switch',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Set',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Switch': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Set', type: 'main', index: 999 }] // Very large index
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should validate without crashing (n8n allows large indices)
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Malformed connection structures', () => {
|
||||||
|
it('should handle null connection objects', async () => {
|
||||||
|
// Use default mock that includes outputs for SplitInBatches
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Null Connections Workflow',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
null, // Null output
|
||||||
|
[{ node: 'NonExistent', type: 'main', index: 0 }]
|
||||||
|
] as any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should handle gracefully without crashing
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing connection properties', async () => {
|
||||||
|
// Use default mock that includes outputs for SplitInBatches
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Malformed Connections Workflow',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Set',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[
|
||||||
|
{ node: 'Set' } as any, // Missing type and index
|
||||||
|
{ type: 'main', index: 0 } as any, // Missing node
|
||||||
|
{} as any // Empty object
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should handle malformed connections but report errors
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Deep loop back detection limits', () => {
|
||||||
|
it('should respect maxDepth limit in checkForLoopBack', async () => {
|
||||||
|
// Use default mock that includes outputs for SplitInBatches
|
||||||
|
|
||||||
|
// Create a very deep chain that exceeds maxDepth (50)
|
||||||
|
const nodes = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const connections: any = {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[], // Done output
|
||||||
|
[{ node: 'Node1', type: 'main', index: 0 }] // Loop output
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create chain of 60 nodes (exceeds maxDepth of 50)
|
||||||
|
for (let i = 1; i <= 60; i++) {
|
||||||
|
nodes.push({
|
||||||
|
id: (i + 1).toString(),
|
||||||
|
name: `Node${i}`,
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [100 + i * 50, 100],
|
||||||
|
parameters: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (i < 60) {
|
||||||
|
connections[`Node${i}`] = {
|
||||||
|
main: [[{ node: `Node${i + 1}`, type: 'main', index: 0 }]]
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Last node connects back to Split In Batches
|
||||||
|
connections[`Node${i}`] = {
|
||||||
|
main: [[{ node: 'Split In Batches', type: 'main', index: 0 }]]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Deep Chain Workflow',
|
||||||
|
nodes,
|
||||||
|
connections
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should warn about missing loop back because depth limit prevents detection
|
||||||
|
const loopBackWarnings = result.warnings.filter(w =>
|
||||||
|
w.message?.includes('doesn\'t connect back')
|
||||||
|
);
|
||||||
|
expect(loopBackWarnings).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle circular references without infinite loops', async () => {
|
||||||
|
// Use default mock that includes outputs for SplitInBatches
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Circular Reference Workflow',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'NodeA',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'NodeB',
|
||||||
|
type: 'n8n-nodes-base.function',
|
||||||
|
position: [500, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[],
|
||||||
|
[{ node: 'NodeA', type: 'main', index: 0 }]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'NodeA': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'NodeB', type: 'main', index: 0 }]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'NodeB': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'NodeA', type: 'main', index: 0 }] // Circular: B -> A -> B -> A ...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should complete without hanging and warn about missing loop back
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
const loopBackWarnings = result.warnings.filter(w =>
|
||||||
|
w.message?.includes('doesn\'t connect back')
|
||||||
|
);
|
||||||
|
expect(loopBackWarnings).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle self-referencing nodes in loop back detection', async () => {
|
||||||
|
// Use default mock that includes outputs for SplitInBatches
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Self Reference Workflow',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'SelfRef',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[],
|
||||||
|
[{ node: 'SelfRef', type: 'main', index: 0 }]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'SelfRef': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'SelfRef', type: 'main', index: 0 }] // Self-reference instead of loop back
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should warn about missing loop back and self-reference
|
||||||
|
const loopBackWarnings = result.warnings.filter(w =>
|
||||||
|
w.message?.includes('doesn\'t connect back')
|
||||||
|
);
|
||||||
|
const selfRefWarnings = result.warnings.filter(w =>
|
||||||
|
w.message?.includes('self-referencing')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(loopBackWarnings).toHaveLength(1);
|
||||||
|
expect(selfRefWarnings).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Complex output structures', () => {
|
||||||
|
it('should handle nodes with many outputs', async () => {
|
||||||
|
const manyOutputs = Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
displayName: `Output ${i + 1}`,
|
||||||
|
name: `output${i + 1}`,
|
||||||
|
description: `Output number ${i + 1}`
|
||||||
|
}));
|
||||||
|
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.complexSwitch',
|
||||||
|
outputs: manyOutputs,
|
||||||
|
outputNames: manyOutputs.map(o => o.name),
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Many Outputs Workflow',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Complex Switch',
|
||||||
|
type: 'n8n-nodes-base.complexSwitch',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Set',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Complex Switch': {
|
||||||
|
main: Array.from({ length: 20 }, () => [
|
||||||
|
{ node: 'Set', type: 'main', index: 0 }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should handle without performance issues
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed output types (main, error, ai_tool)', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.complexNode',
|
||||||
|
outputs: [
|
||||||
|
{ displayName: 'Main', type: 'main' },
|
||||||
|
{ displayName: 'Error', type: 'error' }
|
||||||
|
],
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Mixed Output Types Workflow',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Complex Node',
|
||||||
|
type: 'n8n-nodes-base.complexNode',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Main Handler',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [300, 50],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Error Handler',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [300, 150],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
name: 'Tool',
|
||||||
|
type: 'n8n-nodes-base.httpRequest',
|
||||||
|
position: [500, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Complex Node': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Main Handler', type: 'main', index: 0 }]
|
||||||
|
],
|
||||||
|
error: [
|
||||||
|
[{ node: 'Error Handler', type: 'main', index: 0 }]
|
||||||
|
],
|
||||||
|
ai_tool: [
|
||||||
|
[{ node: 'Tool', type: 'main', index: 0 }]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should validate all connection types
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.statistics.validConnections).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SplitInBatches specific edge cases', () => {
|
||||||
|
it('should handle SplitInBatches with no connections', async () => {
|
||||||
|
// Use default mock that includes outputs for SplitInBatches
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Isolated SplitInBatches',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should not produce SplitInBatches-specific warnings for isolated node
|
||||||
|
const splitWarnings = result.warnings.filter(w =>
|
||||||
|
w.message?.includes('SplitInBatches') ||
|
||||||
|
w.message?.includes('loop') ||
|
||||||
|
w.message?.includes('done')
|
||||||
|
);
|
||||||
|
expect(splitWarnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle SplitInBatches with only one output connected', async () => {
|
||||||
|
// Use default mock that includes outputs for SplitInBatches
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Single Output SplitInBatches',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Final Action',
|
||||||
|
type: 'n8n-nodes-base.emailSend',
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Final Action', type: 'main', index: 0 }], // Only done output connected
|
||||||
|
[] // Loop output empty
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should NOT warn about empty loop output (it's only a problem if loop connects to something but doesn't loop back)
|
||||||
|
// An empty loop output is valid - it just means no looping occurs
|
||||||
|
const loopWarnings = result.warnings.filter(w =>
|
||||||
|
w.message?.includes('loop') && w.message?.includes('connect back')
|
||||||
|
);
|
||||||
|
expect(loopWarnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle SplitInBatches with both outputs to same node', async () => {
|
||||||
|
// Use default mock that includes outputs for SplitInBatches
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Same Target SplitInBatches',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Multi Purpose',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Multi Purpose', type: 'main', index: 0 }], // Done -> Multi Purpose
|
||||||
|
[{ node: 'Multi Purpose', type: 'main', index: 0 }] // Loop -> Multi Purpose
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'Multi Purpose': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Split In Batches', type: 'main', index: 0 }] // Loop back
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Both outputs go to same node which loops back - should be valid
|
||||||
|
// No warnings about loop back since it does connect back
|
||||||
|
const loopWarnings = result.warnings.filter(w =>
|
||||||
|
w.message?.includes('loop') && w.message?.includes('connect back')
|
||||||
|
);
|
||||||
|
expect(loopWarnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect reversed outputs with processing node on done output', async () => {
|
||||||
|
// Use default mock that includes outputs for SplitInBatches
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Reversed SplitInBatches with Function Node',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Process Function',
|
||||||
|
type: 'n8n-nodes-base.function',
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Process Function', type: 'main', index: 0 }], // Done -> Function (this is wrong)
|
||||||
|
[] // Loop output empty
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'Process Function': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Split In Batches', type: 'main', index: 0 }] // Function connects back (indicates it should be on loop)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should error about reversed outputs since function node on done output connects back
|
||||||
|
const reversedErrors = result.errors.filter(e =>
|
||||||
|
e.message?.includes('SplitInBatches outputs appear reversed')
|
||||||
|
);
|
||||||
|
expect(reversedErrors).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-existent node type gracefully', async () => {
|
||||||
|
// Node doesn't exist in repository
|
||||||
|
mockNodeRepository.getNode.mockReturnValue(null);
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Unknown Node Type',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Unknown Node',
|
||||||
|
type: 'n8n-nodes-base.unknownNode',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should report unknown node type error
|
||||||
|
const unknownNodeErrors = result.errors.filter(e =>
|
||||||
|
e.message?.includes('Unknown node type')
|
||||||
|
);
|
||||||
|
expect(unknownNodeErrors).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Performance edge cases', () => {
|
||||||
|
it('should handle very large workflows efficiently', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.set',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create workflow with 1000 nodes
|
||||||
|
const nodes = Array.from({ length: 1000 }, (_, i) => ({
|
||||||
|
id: `node${i}`,
|
||||||
|
name: `Node ${i}`,
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [100 + (i % 50) * 50, 100 + Math.floor(i / 50) * 50],
|
||||||
|
parameters: {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Create simple linear connections
|
||||||
|
const connections: any = {};
|
||||||
|
for (let i = 0; i < 999; i++) {
|
||||||
|
connections[`Node ${i}`] = {
|
||||||
|
main: [[{ node: `Node ${i + 1}`, type: 'main', index: 0 }]]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Large Workflow',
|
||||||
|
nodes,
|
||||||
|
connections
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
|
||||||
|
// Should complete within reasonable time (< 5 seconds)
|
||||||
|
expect(duration).toBeLessThan(5000);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.statistics.totalNodes).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle workflows with many SplitInBatches nodes', async () => {
|
||||||
|
// Use default mock that includes outputs for SplitInBatches
|
||||||
|
|
||||||
|
// Create 100 SplitInBatches nodes
|
||||||
|
const nodes = Array.from({ length: 100 }, (_, i) => ({
|
||||||
|
id: `split${i}`,
|
||||||
|
name: `Split ${i}`,
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100 + (i % 10) * 100, 100 + Math.floor(i / 10) * 100],
|
||||||
|
parameters: {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const connections: any = {};
|
||||||
|
// Each split connects to the next one
|
||||||
|
for (let i = 0; i < 99; i++) {
|
||||||
|
connections[`Split ${i}`] = {
|
||||||
|
main: [
|
||||||
|
[{ node: `Split ${i + 1}`, type: 'main', index: 0 }], // Done -> next split
|
||||||
|
[] // Empty loop
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Many SplitInBatches Workflow',
|
||||||
|
nodes,
|
||||||
|
connections
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should validate all nodes without performance issues
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.statistics.totalNodes).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
413
tests/unit/services/workflow-fixed-collection-validation.test.ts
Normal file
413
tests/unit/services/workflow-fixed-collection-validation.test.ts
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
/**
|
||||||
|
* Workflow Fixed Collection Validation Tests
|
||||||
|
* Tests that workflow validation catches fixedCollection structure errors at the workflow level
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { WorkflowValidator } from '../../../src/services/workflow-validator';
|
||||||
|
import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator';
|
||||||
|
import { NodeRepository } from '../../../src/database/node-repository';
|
||||||
|
|
||||||
|
describe('Workflow FixedCollection Validation', () => {
|
||||||
|
let validator: WorkflowValidator;
|
||||||
|
let mockNodeRepository: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create mock repository that returns basic node info for common nodes
|
||||||
|
mockNodeRepository = {
|
||||||
|
getNode: vi.fn().mockImplementation((type: string) => {
|
||||||
|
const normalizedType = type.replace('n8n-nodes-base.', '').replace('nodes-base.', '');
|
||||||
|
switch (normalizedType) {
|
||||||
|
case 'webhook':
|
||||||
|
return {
|
||||||
|
nodeType: 'nodes-base.webhook',
|
||||||
|
displayName: 'Webhook',
|
||||||
|
properties: [
|
||||||
|
{ name: 'path', type: 'string', required: true },
|
||||||
|
{ name: 'httpMethod', type: 'options' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
case 'switch':
|
||||||
|
return {
|
||||||
|
nodeType: 'nodes-base.switch',
|
||||||
|
displayName: 'Switch',
|
||||||
|
properties: [
|
||||||
|
{ name: 'rules', type: 'fixedCollection', required: true }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
case 'if':
|
||||||
|
return {
|
||||||
|
nodeType: 'nodes-base.if',
|
||||||
|
displayName: 'If',
|
||||||
|
properties: [
|
||||||
|
{ name: 'conditions', type: 'filter', required: true }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
case 'filter':
|
||||||
|
return {
|
||||||
|
nodeType: 'nodes-base.filter',
|
||||||
|
displayName: 'Filter',
|
||||||
|
properties: [
|
||||||
|
{ name: 'conditions', type: 'filter', required: true }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
validator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should catch invalid Switch node structure in workflow validation', async () => {
|
||||||
|
const workflow = {
|
||||||
|
name: 'Test Workflow with Invalid Switch',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'webhook',
|
||||||
|
name: 'Webhook',
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
position: [0, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
path: 'test-webhook'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'switch',
|
||||||
|
name: 'Switch',
|
||||||
|
type: 'n8n-nodes-base.switch',
|
||||||
|
position: [200, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
// This is the problematic structure that causes "propertyValues[itemName] is not iterable"
|
||||||
|
rules: {
|
||||||
|
conditions: {
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
value1: '={{$json.status}}',
|
||||||
|
operation: 'equals',
|
||||||
|
value2: 'active'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
Webhook: {
|
||||||
|
main: [[{ node: 'Switch', type: 'main', index: 0 }]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow, {
|
||||||
|
validateNodes: true,
|
||||||
|
profile: 'ai-friendly'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
|
||||||
|
const switchError = result.errors.find(e => e.nodeId === 'switch');
|
||||||
|
expect(switchError).toBeDefined();
|
||||||
|
expect(switchError!.message).toContain('propertyValues[itemName] is not iterable');
|
||||||
|
expect(switchError!.message).toContain('Invalid structure for nodes-base.switch node');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should catch invalid If node structure in workflow validation', async () => {
|
||||||
|
const workflow = {
|
||||||
|
name: 'Test Workflow with Invalid If',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'webhook',
|
||||||
|
name: 'Webhook',
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
position: [0, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
path: 'test-webhook'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'if',
|
||||||
|
name: 'If',
|
||||||
|
type: 'n8n-nodes-base.if',
|
||||||
|
position: [200, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
// This is the problematic structure
|
||||||
|
conditions: {
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
value1: '={{$json.age}}',
|
||||||
|
operation: 'largerEqual',
|
||||||
|
value2: 18
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
Webhook: {
|
||||||
|
main: [[{ node: 'If', type: 'main', index: 0 }]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow, {
|
||||||
|
validateNodes: true,
|
||||||
|
profile: 'ai-friendly'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors).toHaveLength(1);
|
||||||
|
|
||||||
|
const ifError = result.errors.find(e => e.nodeId === 'if');
|
||||||
|
expect(ifError).toBeDefined();
|
||||||
|
expect(ifError!.message).toContain('Invalid structure for nodes-base.if node');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept valid Switch node structure in workflow validation', async () => {
|
||||||
|
const workflow = {
|
||||||
|
name: 'Test Workflow with Valid Switch',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'webhook',
|
||||||
|
name: 'Webhook',
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
position: [0, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
path: 'test-webhook'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'switch',
|
||||||
|
name: 'Switch',
|
||||||
|
type: 'n8n-nodes-base.switch',
|
||||||
|
position: [200, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
// This is the correct structure
|
||||||
|
rules: {
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
conditions: {
|
||||||
|
value1: '={{$json.status}}',
|
||||||
|
operation: 'equals',
|
||||||
|
value2: 'active'
|
||||||
|
},
|
||||||
|
outputKey: 'active'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
Webhook: {
|
||||||
|
main: [[{ node: 'Switch', type: 'main', index: 0 }]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow, {
|
||||||
|
validateNodes: true,
|
||||||
|
profile: 'ai-friendly'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not have fixedCollection structure errors
|
||||||
|
const hasFixedCollectionError = result.errors.some(e =>
|
||||||
|
e.message.includes('propertyValues[itemName] is not iterable')
|
||||||
|
);
|
||||||
|
expect(hasFixedCollectionError).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should catch multiple fixedCollection errors in a single workflow', async () => {
|
||||||
|
const workflow = {
|
||||||
|
name: 'Test Workflow with Multiple Invalid Structures',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'webhook',
|
||||||
|
name: 'Webhook',
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
position: [0, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
path: 'test-webhook'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'switch',
|
||||||
|
name: 'Switch',
|
||||||
|
type: 'n8n-nodes-base.switch',
|
||||||
|
position: [200, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
rules: {
|
||||||
|
conditions: {
|
||||||
|
values: [{ value1: 'test', operation: 'equals', value2: 'test' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'if',
|
||||||
|
name: 'If',
|
||||||
|
type: 'n8n-nodes-base.if',
|
||||||
|
position: [400, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
conditions: {
|
||||||
|
values: [{ value1: 'test', operation: 'equals', value2: 'test' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'filter',
|
||||||
|
name: 'Filter',
|
||||||
|
type: 'n8n-nodes-base.filter',
|
||||||
|
position: [600, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
conditions: {
|
||||||
|
values: [{ value1: 'test', operation: 'equals', value2: 'test' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
Webhook: {
|
||||||
|
main: [[{ node: 'Switch', type: 'main', index: 0 }]]
|
||||||
|
},
|
||||||
|
Switch: {
|
||||||
|
main: [
|
||||||
|
[{ node: 'If', type: 'main', index: 0 }],
|
||||||
|
[{ node: 'Filter', type: 'main', index: 0 }]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow, {
|
||||||
|
validateNodes: true,
|
||||||
|
profile: 'ai-friendly'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.length).toBeGreaterThanOrEqual(3); // At least one error for each problematic node
|
||||||
|
|
||||||
|
// Check that each problematic node has an error
|
||||||
|
const switchError = result.errors.find(e => e.nodeId === 'switch');
|
||||||
|
const ifError = result.errors.find(e => e.nodeId === 'if');
|
||||||
|
const filterError = result.errors.find(e => e.nodeId === 'filter');
|
||||||
|
|
||||||
|
expect(switchError).toBeDefined();
|
||||||
|
expect(ifError).toBeDefined();
|
||||||
|
expect(filterError).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should provide helpful statistics about fixedCollection errors', async () => {
|
||||||
|
const workflow = {
|
||||||
|
name: 'Test Workflow Statistics',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'webhook',
|
||||||
|
name: 'Webhook',
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
position: [0, 0] as [number, number],
|
||||||
|
parameters: { path: 'test' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bad-switch',
|
||||||
|
name: 'Bad Switch',
|
||||||
|
type: 'n8n-nodes-base.switch',
|
||||||
|
position: [200, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
rules: {
|
||||||
|
conditions: { values: [{ value1: 'test', operation: 'equals', value2: 'test' }] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'good-switch',
|
||||||
|
name: 'Good Switch',
|
||||||
|
type: 'n8n-nodes-base.switch',
|
||||||
|
position: [400, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
rules: {
|
||||||
|
values: [{ conditions: { value1: 'test', operation: 'equals', value2: 'test' }, outputKey: 'out' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
Webhook: {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Bad Switch', type: 'main', index: 0 }],
|
||||||
|
[{ node: 'Good Switch', type: 'main', index: 0 }]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow, {
|
||||||
|
validateNodes: true,
|
||||||
|
profile: 'ai-friendly'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.statistics.totalNodes).toBe(3);
|
||||||
|
expect(result.statistics.enabledNodes).toBe(3);
|
||||||
|
expect(result.valid).toBe(false); // Should be invalid due to the bad switch
|
||||||
|
|
||||||
|
// Should have at least one error for the bad switch
|
||||||
|
const badSwitchError = result.errors.find(e => e.nodeId === 'bad-switch');
|
||||||
|
expect(badSwitchError).toBeDefined();
|
||||||
|
|
||||||
|
// Should not have errors for the good switch or webhook
|
||||||
|
const goodSwitchError = result.errors.find(e => e.nodeId === 'good-switch');
|
||||||
|
const webhookError = result.errors.find(e => e.nodeId === 'webhook');
|
||||||
|
|
||||||
|
// These might have other validation errors, but not fixedCollection errors
|
||||||
|
if (goodSwitchError) {
|
||||||
|
expect(goodSwitchError.message).not.toContain('propertyValues[itemName] is not iterable');
|
||||||
|
}
|
||||||
|
if (webhookError) {
|
||||||
|
expect(webhookError.message).not.toContain('propertyValues[itemName] is not iterable');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should work with different validation profiles', async () => {
|
||||||
|
const workflow = {
|
||||||
|
name: 'Test Profile Compatibility',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'switch',
|
||||||
|
name: 'Switch',
|
||||||
|
type: 'n8n-nodes-base.switch',
|
||||||
|
position: [0, 0] as [number, number],
|
||||||
|
parameters: {
|
||||||
|
rules: {
|
||||||
|
conditions: {
|
||||||
|
values: [{ value1: 'test', operation: 'equals', value2: 'test' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const profiles: Array<'strict' | 'runtime' | 'ai-friendly' | 'minimal'> =
|
||||||
|
['strict', 'runtime', 'ai-friendly', 'minimal'];
|
||||||
|
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const result = await validator.validateWorkflow(workflow, {
|
||||||
|
validateNodes: true,
|
||||||
|
profile
|
||||||
|
});
|
||||||
|
|
||||||
|
// All profiles should catch this critical error
|
||||||
|
const hasCriticalError = result.errors.some(e =>
|
||||||
|
e.message.includes('propertyValues[itemName] is not iterable')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hasCriticalError, `Profile ${profile} should catch critical fixedCollection error`).toBe(true);
|
||||||
|
expect(result.valid, `Profile ${profile} should mark workflow as invalid`).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -223,7 +223,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
it('should error when nodes array is missing', async () => {
|
it('should error when nodes array is missing', async () => {
|
||||||
const workflow = { connections: {} } as any;
|
const workflow = { connections: {} } as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.message === 'Workflow must have a nodes array')).toBe(true);
|
expect(result.errors.some(e => e.message === 'Workflow must have a nodes array')).toBe(true);
|
||||||
@@ -232,7 +232,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
it('should error when connections object is missing', async () => {
|
it('should error when connections object is missing', async () => {
|
||||||
const workflow = { nodes: [] } as any;
|
const workflow = { nodes: [] } as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.message === 'Workflow must have a connections object')).toBe(true);
|
expect(result.errors.some(e => e.message === 'Workflow must have a connections object')).toBe(true);
|
||||||
@@ -241,7 +241,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
it('should warn when workflow has no nodes', async () => {
|
it('should warn when workflow has no nodes', async () => {
|
||||||
const workflow = { nodes: [], connections: {} } as any;
|
const workflow = { nodes: [], connections: {} } as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.valid).toBe(true); // Empty workflows are valid but get a warning
|
expect(result.valid).toBe(true); // Empty workflows are valid but get a warning
|
||||||
expect(result.warnings).toHaveLength(1);
|
expect(result.warnings).toHaveLength(1);
|
||||||
@@ -260,7 +260,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.message.includes('Single-node workflows are only valid for webhook endpoints'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Single-node workflows are only valid for webhook endpoints'))).toBe(true);
|
||||||
@@ -279,7 +279,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
expect(result.warnings.some(w => w.message.includes('Webhook node has no connections'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('Webhook node has no connections'))).toBe(true);
|
||||||
@@ -306,7 +306,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.message.includes('Multi-node workflow has no connections'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Multi-node workflow has no connections'))).toBe(true);
|
||||||
@@ -333,7 +333,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.errors.some(e => e.message.includes('Duplicate node name: "Webhook"'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Duplicate node name: "Webhook"'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -359,7 +359,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.errors.some(e => e.message.includes('Duplicate node ID: "1"'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Duplicate node ID: "1"'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -392,7 +392,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.statistics.triggerNodes).toBe(3);
|
expect(result.statistics.triggerNodes).toBe(3);
|
||||||
});
|
});
|
||||||
@@ -422,7 +422,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
}
|
}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.warnings.some(w => w.message.includes('Workflow has no trigger nodes'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('Workflow has no trigger nodes'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -449,7 +449,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.statistics.totalNodes).toBe(2);
|
expect(result.statistics.totalNodes).toBe(2);
|
||||||
expect(result.statistics.enabledNodes).toBe(1);
|
expect(result.statistics.enabledNodes).toBe(1);
|
||||||
@@ -472,7 +472,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(mockNodeRepository.getNode).not.toHaveBeenCalled();
|
expect(mockNodeRepository.getNode).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -491,7 +491,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.message.includes('Invalid node type: "nodes-base.webhook"'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Invalid node type: "nodes-base.webhook"'))).toBe(true);
|
||||||
@@ -512,7 +512,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
expect(result.errors.some(e => e.message.includes('Unknown node type: "httpRequest"'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Unknown node type: "httpRequest"'))).toBe(true);
|
||||||
@@ -533,7 +533,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(mockNodeRepository.getNode).toHaveBeenCalledWith('n8n-nodes-base.webhook');
|
expect(mockNodeRepository.getNode).toHaveBeenCalledWith('n8n-nodes-base.webhook');
|
||||||
expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-base.webhook');
|
expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-base.webhook');
|
||||||
@@ -553,7 +553,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(mockNodeRepository.getNode).toHaveBeenCalledWith('@n8n/n8n-nodes-langchain.agent');
|
expect(mockNodeRepository.getNode).toHaveBeenCalledWith('@n8n/n8n-nodes-langchain.agent');
|
||||||
expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-langchain.agent');
|
expect(mockNodeRepository.getNode).toHaveBeenCalledWith('nodes-langchain.agent');
|
||||||
@@ -574,7 +574,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.errors.some(e => e.message.includes('Missing required property \'typeVersion\''))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Missing required property \'typeVersion\''))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -594,7 +594,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.errors.some(e => e.message.includes('Invalid typeVersion: invalid'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Invalid typeVersion: invalid'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -614,7 +614,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.warnings.some(w => w.message.includes('Outdated typeVersion: 1. Latest is 2'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('Outdated typeVersion: 1. Latest is 2'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -634,7 +634,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.errors.some(e => e.message.includes('typeVersion 10 exceeds maximum supported version 2'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('typeVersion 10 exceeds maximum supported version 2'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -664,7 +664,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.errors.some(e => e.message.includes('Missing required field: url'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Missing required field: url'))).toBe(true);
|
||||||
expect(result.warnings.some(w => w.message.includes('Consider using HTTPS'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('Consider using HTTPS'))).toBe(true);
|
||||||
@@ -689,7 +689,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.errors.some(e => e.message.includes('Failed to validate node: Validation error'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Failed to validate node: Validation error'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -721,7 +721,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
}
|
}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.statistics.validConnections).toBe(1);
|
expect(result.statistics.validConnections).toBe(1);
|
||||||
expect(result.statistics.invalidConnections).toBe(0);
|
expect(result.statistics.invalidConnections).toBe(0);
|
||||||
@@ -745,7 +745,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
}
|
}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.errors.some(e => e.message.includes('Connection from non-existent node: "NonExistent"'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Connection from non-existent node: "NonExistent"'))).toBe(true);
|
||||||
expect(result.statistics.invalidConnections).toBe(1);
|
expect(result.statistics.invalidConnections).toBe(1);
|
||||||
@@ -776,7 +776,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
}
|
}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.errors.some(e => e.message.includes('Connection uses node ID \'webhook-id\' instead of node name \'Webhook\''))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Connection uses node ID \'webhook-id\' instead of node name \'Webhook\''))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -799,7 +799,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
}
|
}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.errors.some(e => e.message.includes('Connection to non-existent node: "NonExistent"'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Connection to non-existent node: "NonExistent"'))).toBe(true);
|
||||||
expect(result.statistics.invalidConnections).toBe(1);
|
expect(result.statistics.invalidConnections).toBe(1);
|
||||||
@@ -830,7 +830,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
}
|
}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.errors.some(e => e.message.includes('Connection target uses node ID \'set-id\' instead of node name \'Set\''))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Connection target uses node ID \'set-id\' instead of node name \'Set\''))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -861,7 +861,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
}
|
}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.warnings.some(w => w.message.includes('Connection to disabled node: "Set"'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('Connection to disabled node: "Set"'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -891,7 +891,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
}
|
}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.statistics.validConnections).toBe(1);
|
expect(result.statistics.validConnections).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -921,7 +921,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
}
|
}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.statistics.validConnections).toBe(1);
|
expect(result.statistics.validConnections).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -953,7 +953,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
}
|
}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.warnings.some(w => w.message.includes('Community node "CustomTool" is being used as an AI tool'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('Community node "CustomTool" is being used as an AI tool'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -990,7 +990,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
}
|
}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.warnings.some(w => w.message.includes('Node is not connected to any other nodes') && w.nodeName === 'Orphaned')).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('Node is not connected to any other nodes') && w.nodeName === 'Orphaned')).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1033,7 +1033,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
}
|
}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.errors.some(e => e.message.includes('Workflow contains a cycle'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Workflow contains a cycle'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1068,7 +1068,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
}
|
}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.statistics.validConnections).toBe(1);
|
expect(result.statistics.validConnections).toBe(1);
|
||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
@@ -1110,7 +1110,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
}
|
}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(ExpressionValidator.validateNodeExpressions).toHaveBeenCalledWith(
|
expect(ExpressionValidator.validateNodeExpressions).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ values: expect.any(Object) }),
|
expect.objectContaining({ values: expect.any(Object) }),
|
||||||
@@ -1146,7 +1146,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.errors.some(e => e.message.includes('Expression error: Invalid expression syntax'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Expression error: Invalid expression syntax'))).toBe(true);
|
||||||
expect(result.warnings.some(w => w.message.includes('Expression warning: Deprecated variable usage'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('Expression warning: Deprecated variable usage'))).toBe(true);
|
||||||
@@ -1170,7 +1170,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(ExpressionValidator.validateNodeExpressions).not.toHaveBeenCalled();
|
expect(ExpressionValidator.validateNodeExpressions).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1187,7 +1187,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
|
|
||||||
const workflow = builder.build() as any;
|
const workflow = builder.build() as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.warnings.some(w => w.message.includes('Consider adding error handling'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('Consider adding error handling'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1208,7 +1208,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
|
|
||||||
const workflow = builder.build() as any;
|
const workflow = builder.build() as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.warnings.some(w => w.message.includes('Long linear chain detected'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('Long linear chain detected'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1230,7 +1230,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.warnings.some(w => w.message.includes('Missing credentials configuration for slackApi'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('Missing credentials configuration for slackApi'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1249,7 +1249,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('AI Agent has no tools connected'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1279,7 +1279,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
}
|
}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.suggestions.some(s => s.includes('N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE'))).toBe(true);
|
expect(result.suggestions.some(s => s.includes('N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1306,7 +1306,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.errors.some(e => e.message.includes('Node-level properties onError, retryOnFail, credentials are in the wrong location'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Node-level properties onError, retryOnFail, credentials are in the wrong location'))).toBe(true);
|
||||||
expect(result.errors.some(e => e.details?.fix?.includes('Move these properties from node.parameters to the node level'))).toBe(true);
|
expect(result.errors.some(e => e.details?.fix?.includes('Move these properties from node.parameters to the node level'))).toBe(true);
|
||||||
@@ -1327,7 +1327,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.errors.some(e => e.message.includes('Invalid onError value: "invalidValue"'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Invalid onError value: "invalidValue"'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1347,7 +1347,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.warnings.some(w => w.message.includes('Using deprecated "continueOnFail: true"'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('Using deprecated "continueOnFail: true"'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1368,7 +1368,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.errors.some(e => e.message.includes('Cannot use both "continueOnFail" and "onError" properties'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Cannot use both "continueOnFail" and "onError" properties'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1390,7 +1390,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.errors.some(e => e.message.includes('maxTries must be a positive number'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('maxTries must be a positive number'))).toBe(true);
|
||||||
expect(result.errors.some(e => e.message.includes('waitBetweenTries must be a non-negative number'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('waitBetweenTries must be a non-negative number'))).toBe(true);
|
||||||
@@ -1413,7 +1413,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.warnings.some(w => w.message.includes('maxTries is set to 15'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('maxTries is set to 15'))).toBe(true);
|
||||||
expect(result.warnings.some(w => w.message.includes('waitBetweenTries is set to 400000ms'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('waitBetweenTries is set to 400000ms'))).toBe(true);
|
||||||
@@ -1434,7 +1434,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.warnings.some(w => w.message.includes('retryOnFail is enabled but maxTries is not specified'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('retryOnFail is enabled but maxTries is not specified'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1459,7 +1459,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
|
||||||
expect(result.errors.some(e => e.message.includes('alwaysOutputData must be a boolean'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('alwaysOutputData must be a boolean'))).toBe(true);
|
||||||
@@ -1484,7 +1484,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.warnings.some(w => w.message.includes('executeOnce is enabled'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('executeOnce is enabled'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1512,7 +1512,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.warnings.some(w => w.message.includes(nodeInfo.message) && w.message.includes('without error handling'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes(nodeInfo.message) && w.message.includes('without error handling'))).toBe(true);
|
||||||
}
|
}
|
||||||
@@ -1534,7 +1534,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.warnings.some(w => w.message.includes('Both continueOnFail and retryOnFail are enabled'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('Both continueOnFail and retryOnFail are enabled'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1554,7 +1554,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.suggestions.some(s => s.includes('Consider enabling alwaysOutputData'))).toBe(true);
|
expect(result.suggestions.some(s => s.includes('Consider enabling alwaysOutputData'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1569,7 +1569,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
|
|
||||||
const workflow = builder.build() as any;
|
const workflow = builder.build() as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.suggestions.some(s => s.includes('Most nodes lack error handling'))).toBe(true);
|
expect(result.suggestions.some(s => s.includes('Most nodes lack error handling'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1589,7 +1589,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.suggestions.some(s => s.includes('Replace "continueOnFail: true" with "onError:'))).toBe(true);
|
expect(result.suggestions.some(s => s.includes('Replace "continueOnFail: true" with "onError:'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1610,7 +1610,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.suggestions.some(s => s.includes('Add a trigger node'))).toBe(true);
|
expect(result.suggestions.some(s => s.includes('Add a trigger node'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1636,7 +1636,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {} // Missing connections
|
connections: {} // Missing connections
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.suggestions.some(s => s.includes('Example connection structure'))).toBe(true);
|
expect(result.suggestions.some(s => s.includes('Example connection structure'))).toBe(true);
|
||||||
expect(result.suggestions.some(s => s.includes('Use node NAMES (not IDs) in connections'))).toBe(true);
|
expect(result.suggestions.some(s => s.includes('Use node NAMES (not IDs) in connections'))).toBe(true);
|
||||||
@@ -1667,7 +1667,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
}
|
}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.suggestions.some(s => s.includes('Add error handling'))).toBe(true);
|
expect(result.suggestions.some(s => s.includes('Add error handling'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1682,7 +1682,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
|
|
||||||
const workflow = builder.build() as any;
|
const workflow = builder.build() as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.suggestions.some(s => s.includes('Consider breaking this workflow into smaller sub-workflows'))).toBe(true);
|
expect(result.suggestions.some(s => s.includes('Consider breaking this workflow into smaller sub-workflows'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1708,7 +1708,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.suggestions.some(s => s.includes('Consider using a Code node for complex data transformations'))).toBe(true);
|
expect(result.suggestions.some(s => s.includes('Consider using a Code node for complex data transformations'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1727,7 +1727,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.suggestions.some(s => s.includes('A minimal workflow needs'))).toBe(true);
|
expect(result.suggestions.some(s => s.includes('A minimal workflow needs'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -1756,7 +1756,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.errors.some(e => e.message.includes(`Did you mean`) && e.message.includes(testCase.suggestion))).toBe(true);
|
expect(result.errors.some(e => e.message.includes(`Did you mean`) && e.message.includes(testCase.suggestion))).toBe(true);
|
||||||
}
|
}
|
||||||
@@ -1848,7 +1848,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
}
|
}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
// Should have multiple errors
|
// Should have multiple errors
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
@@ -1940,7 +1940,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => {
|
|||||||
}
|
}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
expect(result.errors).toHaveLength(0);
|
expect(result.errors).toHaveLength(0);
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ describe('WorkflowValidator - Edge Cases', () => {
|
|||||||
nodes: [],
|
nodes: [],
|
||||||
connections: {}
|
connections: {}
|
||||||
};
|
};
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
expect(result.warnings.some(w => w.message.includes('empty'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('empty'))).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -181,7 +181,7 @@ describe('WorkflowValidator - Edge Cases', () => {
|
|||||||
const workflow = { nodes, connections };
|
const workflow = { nodes, connections };
|
||||||
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
const duration = Date.now() - start;
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
@@ -207,7 +207,7 @@ describe('WorkflowValidator - Edge Cases', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
expect(result.statistics.invalidConnections).toBe(0);
|
expect(result.statistics.invalidConnections).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -228,7 +228,7 @@ describe('WorkflowValidator - Edge Cases', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -264,7 +264,7 @@ describe('WorkflowValidator - Edge Cases', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
expect(result.errors.length).toBeGreaterThan(0);
|
expect(result.errors.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -292,7 +292,7 @@ describe('WorkflowValidator - Edge Cases', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
expect(result.warnings.some(w => w.message.includes('self-referencing'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('self-referencing'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -308,7 +308,7 @@ describe('WorkflowValidator - Edge Cases', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
expect(result.errors.some(e => e.message.includes('non-existent'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('non-existent'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -324,7 +324,7 @@ describe('WorkflowValidator - Edge Cases', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
expect(result.errors.length).toBeGreaterThan(0);
|
expect(result.errors.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -341,7 +341,7 @@ describe('WorkflowValidator - Edge Cases', () => {
|
|||||||
} as any
|
} as any
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
// Should still work as type and index can have defaults
|
// Should still work as type and index can have defaults
|
||||||
expect(result.statistics.validConnections).toBeGreaterThan(0);
|
expect(result.statistics.validConnections).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
@@ -359,7 +359,7 @@ describe('WorkflowValidator - Edge Cases', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
expect(result.errors.some(e => e.message.includes('Invalid'))).toBe(true);
|
expect(result.errors.some(e => e.message.includes('Invalid'))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -382,7 +382,7 @@ describe('WorkflowValidator - Edge Cases', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -395,7 +395,7 @@ describe('WorkflowValidator - Edge Cases', () => {
|
|||||||
connections: {}
|
connections: {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
expect(result.warnings.some(w => w.message.includes('very long'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('very long'))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -479,7 +479,7 @@ describe('WorkflowValidator - Edge Cases', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
expect(result.statistics.validConnections).toBeGreaterThan(0);
|
expect(result.statistics.validConnections).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -499,7 +499,7 @@ describe('WorkflowValidator - Edge Cases', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
expect(result.errors.length).toBeGreaterThan(0);
|
expect(result.errors.length).toBeGreaterThan(0);
|
||||||
expect(result.statistics.validConnections).toBeGreaterThan(0);
|
expect(result.statistics.validConnections).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|||||||
434
tests/unit/services/workflow-validator-loops-simple.test.ts
Normal file
434
tests/unit/services/workflow-validator-loops-simple.test.ts
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { WorkflowValidator } from '@/services/workflow-validator';
|
||||||
|
import { NodeRepository } from '@/database/node-repository';
|
||||||
|
import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@/database/node-repository');
|
||||||
|
vi.mock('@/services/enhanced-config-validator');
|
||||||
|
|
||||||
|
describe('WorkflowValidator - SplitInBatches Validation (Simplified)', () => {
|
||||||
|
let validator: WorkflowValidator;
|
||||||
|
let mockNodeRepository: any;
|
||||||
|
let mockNodeValidator: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockNodeRepository = {
|
||||||
|
getNode: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
mockNodeValidator = {
|
||||||
|
validateWithMode: vi.fn().mockReturnValue({
|
||||||
|
errors: [],
|
||||||
|
warnings: []
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
validator = new WorkflowValidator(mockNodeRepository, mockNodeValidator);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SplitInBatches node detection', () => {
|
||||||
|
it('should identify SplitInBatches nodes in workflow', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'SplitInBatches Workflow',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: { batchSize: 10 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Process Item',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[], // Done output (0)
|
||||||
|
[{ node: 'Process Item', type: 'main', index: 0 }] // Loop output (1)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should complete validation without crashing
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.valid).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle SplitInBatches with processing node name patterns', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const processingNames = [
|
||||||
|
'Process Item',
|
||||||
|
'Transform Data',
|
||||||
|
'Handle Each',
|
||||||
|
'Function Node',
|
||||||
|
'Code Block'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const nodeName of processingNames) {
|
||||||
|
const workflow = {
|
||||||
|
name: 'Processing Pattern Test',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: nodeName,
|
||||||
|
type: 'n8n-nodes-base.function',
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[{ node: nodeName, type: 'main', index: 0 }], // Processing node on Done output
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should identify potential processing nodes
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle final processing node patterns', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalNames = [
|
||||||
|
'Final Summary',
|
||||||
|
'Send Email',
|
||||||
|
'Complete Notification',
|
||||||
|
'Final Report'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const nodeName of finalNames) {
|
||||||
|
const workflow = {
|
||||||
|
name: 'Final Pattern Test',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: nodeName,
|
||||||
|
type: 'n8n-nodes-base.emailSend',
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[{ node: nodeName, type: 'main', index: 0 }], // Final node on Done output (correct)
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should not warn about final nodes on done output
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Connection validation', () => {
|
||||||
|
it('should validate connection indices', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Connection Index Test',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Target',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Target', type: 'main', index: -1 }] // Invalid negative index
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
const negativeIndexErrors = result.errors.filter(e =>
|
||||||
|
e.message?.includes('Invalid connection index -1')
|
||||||
|
);
|
||||||
|
expect(negativeIndexErrors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-existent target nodes', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Missing Target Test',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'NonExistentNode', type: 'main', index: 0 }]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
const missingNodeErrors = result.errors.filter(e =>
|
||||||
|
e.message?.includes('non-existent node')
|
||||||
|
);
|
||||||
|
expect(missingNodeErrors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Self-referencing connections', () => {
|
||||||
|
it('should allow self-referencing for SplitInBatches nodes', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Self Reference Test',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[],
|
||||||
|
[{ node: 'Split In Batches', type: 'main', index: 0 }] // Self-reference on loop output
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should not warn about self-reference for SplitInBatches
|
||||||
|
const selfRefWarnings = result.warnings.filter(w =>
|
||||||
|
w.message?.includes('self-referencing')
|
||||||
|
);
|
||||||
|
expect(selfRefWarnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn about self-referencing for non-loop nodes', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.set',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Non-Loop Self Reference Test',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Set',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Set': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Set', type: 'main', index: 0 }] // Self-reference on regular node
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should warn about self-reference for non-loop nodes
|
||||||
|
const selfRefWarnings = result.warnings.filter(w =>
|
||||||
|
w.message?.includes('self-referencing')
|
||||||
|
);
|
||||||
|
expect(selfRefWarnings.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Output connection validation', () => {
|
||||||
|
it('should validate output connections for nodes with outputs', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.if',
|
||||||
|
outputs: [
|
||||||
|
{ displayName: 'True', description: 'Items that match condition' },
|
||||||
|
{ displayName: 'False', description: 'Items that do not match condition' }
|
||||||
|
],
|
||||||
|
outputNames: ['true', 'false'],
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'IF Node Test',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'IF',
|
||||||
|
type: 'n8n-nodes-base.if',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'True Handler',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [300, 50],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'False Handler',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [300, 150],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'IF': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'True Handler', type: 'main', index: 0 }], // True output (0)
|
||||||
|
[{ node: 'False Handler', type: 'main', index: 0 }] // False output (1)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should validate without major errors
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.statistics.validConnections).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error handling', () => {
|
||||||
|
it('should handle nodes without outputs gracefully', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.httpRequest',
|
||||||
|
outputs: null,
|
||||||
|
outputNames: null,
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'No Outputs Test',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'HTTP Request',
|
||||||
|
type: 'n8n-nodes-base.httpRequest',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should handle gracefully without crashing
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unknown node types gracefully', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue(null);
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Unknown Node Test',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Unknown',
|
||||||
|
type: 'n8n-nodes-base.unknown',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should report unknown node error
|
||||||
|
const unknownErrors = result.errors.filter(e =>
|
||||||
|
e.message?.includes('Unknown node type')
|
||||||
|
);
|
||||||
|
expect(unknownErrors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
705
tests/unit/services/workflow-validator-loops.test.ts
Normal file
705
tests/unit/services/workflow-validator-loops.test.ts
Normal file
@@ -0,0 +1,705 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { WorkflowValidator } from '@/services/workflow-validator';
|
||||||
|
import { NodeRepository } from '@/database/node-repository';
|
||||||
|
import { EnhancedConfigValidator } from '@/services/enhanced-config-validator';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock('@/database/node-repository');
|
||||||
|
vi.mock('@/services/enhanced-config-validator');
|
||||||
|
|
||||||
|
describe('WorkflowValidator - Loop Node Validation', () => {
|
||||||
|
let validator: WorkflowValidator;
|
||||||
|
let mockNodeRepository: any;
|
||||||
|
let mockNodeValidator: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockNodeRepository = {
|
||||||
|
getNode: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
mockNodeValidator = {
|
||||||
|
validateWithMode: vi.fn().mockReturnValue({
|
||||||
|
errors: [],
|
||||||
|
warnings: []
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
validator = new WorkflowValidator(mockNodeRepository, mockNodeValidator);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateSplitInBatchesConnection', () => {
|
||||||
|
const createWorkflow = (connections: any) => ({
|
||||||
|
name: 'Test Workflow',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: { batchSize: 10 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Process Item',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Final Summary',
|
||||||
|
type: 'n8n-nodes-base.emailSend',
|
||||||
|
position: [500, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect reversed SplitInBatches connections (processing node on done output)', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a processing node with a name that matches the pattern (includes "process")
|
||||||
|
const workflow = {
|
||||||
|
name: 'Test Workflow',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: { batchSize: 10 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Process Function', // Name matches processing pattern
|
||||||
|
type: 'n8n-nodes-base.function', // Type also matches processing pattern
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Process Function', type: 'main', index: 0 }], // Done output (wrong for processing)
|
||||||
|
[] // No loop connections
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'Process Function': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Split In Batches', type: 'main', index: 0 }] // Loop back - confirms it's processing
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// The validator should detect the processing node name/type pattern and loop back
|
||||||
|
const reversedErrors = result.errors.filter(e =>
|
||||||
|
e.message?.includes('SplitInBatches outputs appear reversed')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reversedErrors.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn about processing node on done output without loop back', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Processing node connected to "done" output but no loop back
|
||||||
|
const workflow = createWorkflow({
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Process Item', type: 'main', index: 0 }], // Done output
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// No loop back from Process Item
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
expect(result.warnings).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'warning',
|
||||||
|
nodeId: '1',
|
||||||
|
nodeName: 'Split In Batches',
|
||||||
|
message: expect.stringContaining('connected to the "done" output (index 0) but appears to be a processing node')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn about final processing node on loop output', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Final summary node connected to "loop" output (index 1) - suspicious
|
||||||
|
const workflow = createWorkflow({
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[],
|
||||||
|
[{ node: 'Final Summary', type: 'main', index: 0 }] // Loop output for final node
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
expect(result.warnings).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'warning',
|
||||||
|
nodeId: '1',
|
||||||
|
nodeName: 'Split In Batches',
|
||||||
|
message: expect.stringContaining('connected to the "loop" output (index 1) but appears to be a post-processing node')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn about loop output without loop back connection', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Processing node on loop output but doesn't connect back
|
||||||
|
const workflow = createWorkflow({
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[],
|
||||||
|
[{ node: 'Process Item', type: 'main', index: 0 }] // Loop output
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// Process Item doesn't connect back to Split In Batches
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
expect(result.warnings).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'warning',
|
||||||
|
nodeId: '1',
|
||||||
|
nodeName: 'Split In Batches',
|
||||||
|
message: expect.stringContaining('doesn\'t connect back to the SplitInBatches node')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept correct SplitInBatches connections', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a workflow with neutral node names that don't trigger patterns
|
||||||
|
const workflow = {
|
||||||
|
name: 'Test Workflow',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: { batchSize: 10 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Data Node', // Neutral name, won't trigger processing pattern
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Output Node', // Neutral name, won't trigger post-processing pattern
|
||||||
|
type: 'n8n-nodes-base.noOp',
|
||||||
|
position: [500, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Output Node', type: 'main', index: 0 }], // Done output -> neutral node
|
||||||
|
[{ node: 'Data Node', type: 'main', index: 0 }] // Loop output -> neutral node
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'Data Node': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Split In Batches', type: 'main', index: 0 }] // Loop back
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should not have SplitInBatches-specific errors or warnings
|
||||||
|
const splitErrors = result.errors.filter(e =>
|
||||||
|
e.message?.includes('SplitInBatches') ||
|
||||||
|
e.message?.includes('loop') ||
|
||||||
|
e.message?.includes('done')
|
||||||
|
);
|
||||||
|
const splitWarnings = result.warnings.filter(w =>
|
||||||
|
w.message?.includes('SplitInBatches') ||
|
||||||
|
w.message?.includes('loop') ||
|
||||||
|
w.message?.includes('done')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(splitErrors).toHaveLength(0);
|
||||||
|
expect(splitWarnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex loop structures', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const complexWorkflow = {
|
||||||
|
name: 'Complex Loop',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Step A', // Neutral name
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [300, 50],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Step B', // Neutral name
|
||||||
|
type: 'n8n-nodes-base.noOp',
|
||||||
|
position: [500, 50],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
name: 'Final Step', // More neutral name
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [300, 150],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Final Step', type: 'main', index: 0 }], // Done -> Final (correct)
|
||||||
|
[{ node: 'Step A', type: 'main', index: 0 }] // Loop -> Processing (correct)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'Step A': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Step B', type: 'main', index: 0 }]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'Step B': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Split In Batches', type: 'main', index: 0 }] // Loop back (correct)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(complexWorkflow as any);
|
||||||
|
|
||||||
|
// Should accept this correct structure without warnings
|
||||||
|
const loopWarnings = result.warnings.filter(w =>
|
||||||
|
w.message?.includes('loop') || w.message?.includes('done')
|
||||||
|
);
|
||||||
|
expect(loopWarnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect node type patterns for processing detection', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCases = [
|
||||||
|
{ type: 'n8n-nodes-base.function', name: 'Process Data', shouldWarn: true },
|
||||||
|
{ type: 'n8n-nodes-base.code', name: 'Transform Item', shouldWarn: true },
|
||||||
|
{ type: 'n8n-nodes-base.set', name: 'Handle Each', shouldWarn: true },
|
||||||
|
{ type: 'n8n-nodes-base.emailSend', name: 'Final Email', shouldWarn: false },
|
||||||
|
{ type: 'n8n-nodes-base.slack', name: 'Complete Notification', shouldWarn: false }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
const workflow = {
|
||||||
|
name: 'Pattern Test',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Split In Batches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: testCase.name,
|
||||||
|
type: testCase.type,
|
||||||
|
position: [300, 100],
|
||||||
|
parameters: {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[{ node: testCase.name, type: 'main', index: 0 }], // Connected to done (index 0)
|
||||||
|
[]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
const hasProcessingWarning = result.warnings.some(w =>
|
||||||
|
w.message?.includes('appears to be a processing node')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (testCase.shouldWarn) {
|
||||||
|
expect(hasProcessingWarning).toBe(true);
|
||||||
|
} else {
|
||||||
|
expect(hasProcessingWarning).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkForLoopBack method', () => {
|
||||||
|
it('should detect direct loop back connection', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Direct Loop Back',
|
||||||
|
nodes: [
|
||||||
|
{ id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [0, 0], parameters: {} },
|
||||||
|
{ id: '2', name: 'Process', type: 'n8n-nodes-base.set', position: [0, 0], parameters: {} }
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [[], [{ node: 'Process', type: 'main', index: 0 }]]
|
||||||
|
},
|
||||||
|
'Process': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Split In Batches', type: 'main', index: 0 }] // Direct loop back
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should not warn about missing loop back since it exists
|
||||||
|
const missingLoopBackWarnings = result.warnings.filter(w =>
|
||||||
|
w.message?.includes('doesn\'t connect back')
|
||||||
|
);
|
||||||
|
expect(missingLoopBackWarnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect indirect loop back connection through multiple nodes', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Indirect Loop Back',
|
||||||
|
nodes: [
|
||||||
|
{ id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [0, 0], parameters: {} },
|
||||||
|
{ id: '2', name: 'Step1', type: 'n8n-nodes-base.set', position: [0, 0], parameters: {} },
|
||||||
|
{ id: '3', name: 'Step2', type: 'n8n-nodes-base.function', position: [0, 0], parameters: {} },
|
||||||
|
{ id: '4', name: 'Step3', type: 'n8n-nodes-base.code', position: [0, 0], parameters: {} }
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [[], [{ node: 'Step1', type: 'main', index: 0 }]]
|
||||||
|
},
|
||||||
|
'Step1': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Step2', type: 'main', index: 0 }]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'Step2': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Step3', type: 'main', index: 0 }]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'Step3': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Split In Batches', type: 'main', index: 0 }] // Indirect loop back
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should not warn about missing loop back since indirect loop exists
|
||||||
|
const missingLoopBackWarnings = result.warnings.filter(w =>
|
||||||
|
w.message?.includes('doesn\'t connect back')
|
||||||
|
);
|
||||||
|
expect(missingLoopBackWarnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect max depth to prevent infinite recursion', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a very deep chain that would exceed depth limit
|
||||||
|
const nodes = [
|
||||||
|
{ id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [0, 0], parameters: {} }
|
||||||
|
];
|
||||||
|
const connections: any = {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [[], [{ node: 'Node1', type: 'main', index: 0 }]]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a chain of 60 nodes (exceeds default maxDepth of 50)
|
||||||
|
for (let i = 1; i <= 60; i++) {
|
||||||
|
nodes.push({
|
||||||
|
id: (i + 1).toString(),
|
||||||
|
name: `Node${i}`,
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [0, 0],
|
||||||
|
parameters: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (i < 60) {
|
||||||
|
connections[`Node${i}`] = {
|
||||||
|
main: [[{ node: `Node${i + 1}`, type: 'main', index: 0 }]]
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Last node connects back to Split In Batches
|
||||||
|
connections[`Node${i}`] = {
|
||||||
|
main: [[{ node: 'Split In Batches', type: 'main', index: 0 }]]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Deep Chain',
|
||||||
|
nodes,
|
||||||
|
connections
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should warn about missing loop back because depth limit prevents detection
|
||||||
|
const missingLoopBackWarnings = result.warnings.filter(w =>
|
||||||
|
w.message?.includes('doesn\'t connect back')
|
||||||
|
);
|
||||||
|
expect(missingLoopBackWarnings).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle circular references without infinite loops', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Circular Reference',
|
||||||
|
nodes: [
|
||||||
|
{ id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [0, 0], parameters: {} },
|
||||||
|
{ id: '2', name: 'NodeA', type: 'n8n-nodes-base.set', position: [0, 0], parameters: {} },
|
||||||
|
{ id: '3', name: 'NodeB', type: 'n8n-nodes-base.function', position: [0, 0], parameters: {} }
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [[], [{ node: 'NodeA', type: 'main', index: 0 }]]
|
||||||
|
},
|
||||||
|
'NodeA': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'NodeB', type: 'main', index: 0 }]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'NodeB': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'NodeA', type: 'main', index: 0 }] // Circular reference (doesn't connect back to Split)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should complete without hanging and warn about missing loop back
|
||||||
|
const missingLoopBackWarnings = result.warnings.filter(w =>
|
||||||
|
w.message?.includes('doesn\'t connect back')
|
||||||
|
);
|
||||||
|
expect(missingLoopBackWarnings).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('self-referencing connections', () => {
|
||||||
|
it('should allow self-referencing for SplitInBatches (loop back)', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Self Reference Loop',
|
||||||
|
nodes: [
|
||||||
|
{ id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [0, 0], parameters: {} }
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[],
|
||||||
|
[{ node: 'Split In Batches', type: 'main', index: 0 }] // Self-reference on loop output
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should not warn about self-reference for SplitInBatches
|
||||||
|
const selfReferenceWarnings = result.warnings.filter(w =>
|
||||||
|
w.message?.includes('self-referencing')
|
||||||
|
);
|
||||||
|
expect(selfReferenceWarnings).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn about self-referencing for non-loop nodes', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.set',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Non-Loop Self Reference',
|
||||||
|
nodes: [
|
||||||
|
{ id: '1', name: 'Set', type: 'n8n-nodes-base.set', position: [0, 0], parameters: {} }
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Set': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Set', type: 'main', index: 0 }] // Self-reference on regular node
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should warn about self-reference for non-loop nodes
|
||||||
|
const selfReferenceWarnings = result.warnings.filter(w =>
|
||||||
|
w.message?.includes('self-referencing')
|
||||||
|
);
|
||||||
|
expect(selfReferenceWarnings).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('should handle missing target node gracefully', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Missing Target',
|
||||||
|
nodes: [
|
||||||
|
{ id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [0, 0], parameters: {} }
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[],
|
||||||
|
[{ node: 'NonExistentNode', type: 'main', index: 0 }] // Target doesn't exist
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should have connection error for non-existent node
|
||||||
|
const connectionErrors = result.errors.filter(e =>
|
||||||
|
e.message?.includes('non-existent node')
|
||||||
|
);
|
||||||
|
expect(connectionErrors).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty connections gracefully', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Empty Connections',
|
||||||
|
nodes: [
|
||||||
|
{ id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [0, 0], parameters: {} }
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
[], // Empty done output
|
||||||
|
[] // Empty loop output
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should not crash and should not have SplitInBatches-specific errors
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null/undefined connection arrays', async () => {
|
||||||
|
mockNodeRepository.getNode.mockReturnValue({
|
||||||
|
nodeType: 'nodes-base.splitInBatches',
|
||||||
|
properties: []
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = {
|
||||||
|
name: 'Null Connections',
|
||||||
|
nodes: [
|
||||||
|
{ id: '1', name: 'Split In Batches', type: 'n8n-nodes-base.splitInBatches', position: [0, 0], parameters: {} }
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Split In Batches': {
|
||||||
|
main: [
|
||||||
|
null, // Null done output
|
||||||
|
undefined // Undefined loop output
|
||||||
|
] as any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
|
// Should handle gracefully without crashing
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -77,7 +77,7 @@ describe('WorkflowValidator - Simple Unit Tests', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
@@ -113,7 +113,7 @@ describe('WorkflowValidator - Simple Unit Tests', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
@@ -154,7 +154,7 @@ describe('WorkflowValidator - Simple Unit Tests', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
@@ -229,7 +229,7 @@ describe('WorkflowValidator - Simple Unit Tests', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(result.valid).toBe(true);
|
expect(result.valid).toBe(true);
|
||||||
@@ -297,7 +297,7 @@ describe('WorkflowValidator - Simple Unit Tests', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
@@ -386,7 +386,7 @@ describe('WorkflowValidator - Simple Unit Tests', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
@@ -438,7 +438,7 @@ describe('WorkflowValidator - Simple Unit Tests', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(result.warnings.some(w => w.message.includes('Outdated typeVersion'))).toBe(true);
|
expect(result.warnings.some(w => w.message.includes('Outdated typeVersion'))).toBe(true);
|
||||||
@@ -471,7 +471,7 @@ describe('WorkflowValidator - Simple Unit Tests', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const result = await validator.validateWorkflow(workflow);
|
const result = await validator.validateWorkflow(workflow as any);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
expect(result.valid).toBe(false);
|
expect(result.valid).toBe(false);
|
||||||
|
|||||||
282
tests/unit/utils/console-manager.test.ts
Normal file
282
tests/unit/utils/console-manager.test.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { ConsoleManager, consoleManager } from '../../../src/utils/console-manager';
|
||||||
|
|
||||||
|
describe('ConsoleManager', () => {
|
||||||
|
let manager: ConsoleManager;
|
||||||
|
let originalEnv: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = new ConsoleManager();
|
||||||
|
originalEnv = process.env.MCP_MODE;
|
||||||
|
// Reset console methods to originals before each test
|
||||||
|
manager.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up after each test
|
||||||
|
manager.restore();
|
||||||
|
if (originalEnv !== undefined) {
|
||||||
|
process.env.MCP_MODE = originalEnv as "test" | "http" | "stdio" | undefined;
|
||||||
|
} else {
|
||||||
|
delete process.env.MCP_MODE;
|
||||||
|
}
|
||||||
|
delete process.env.MCP_REQUEST_ACTIVE;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('silence method', () => {
|
||||||
|
test('should silence console methods when in HTTP mode', () => {
|
||||||
|
process.env.MCP_MODE = 'http';
|
||||||
|
|
||||||
|
const originalLog = console.log;
|
||||||
|
const originalError = console.error;
|
||||||
|
|
||||||
|
manager.silence();
|
||||||
|
|
||||||
|
expect(console.log).not.toBe(originalLog);
|
||||||
|
expect(console.error).not.toBe(originalError);
|
||||||
|
expect(manager.isActive).toBe(true);
|
||||||
|
expect(process.env.MCP_REQUEST_ACTIVE).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not silence when not in HTTP mode', () => {
|
||||||
|
process.env.MCP_MODE = 'stdio';
|
||||||
|
|
||||||
|
const originalLog = console.log;
|
||||||
|
|
||||||
|
manager.silence();
|
||||||
|
|
||||||
|
expect(console.log).toBe(originalLog);
|
||||||
|
expect(manager.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not silence if already silenced', () => {
|
||||||
|
process.env.MCP_MODE = 'http';
|
||||||
|
|
||||||
|
manager.silence();
|
||||||
|
const firstSilencedLog = console.log;
|
||||||
|
|
||||||
|
manager.silence(); // Call again
|
||||||
|
|
||||||
|
expect(console.log).toBe(firstSilencedLog);
|
||||||
|
expect(manager.isActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should silence all console methods', () => {
|
||||||
|
process.env.MCP_MODE = 'http';
|
||||||
|
|
||||||
|
const originalMethods = {
|
||||||
|
log: console.log,
|
||||||
|
error: console.error,
|
||||||
|
warn: console.warn,
|
||||||
|
info: console.info,
|
||||||
|
debug: console.debug,
|
||||||
|
trace: console.trace
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.silence();
|
||||||
|
|
||||||
|
Object.values(originalMethods).forEach(originalMethod => {
|
||||||
|
const currentMethod = Object.values(console).find(method => method === originalMethod);
|
||||||
|
expect(currentMethod).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restore method', () => {
|
||||||
|
test('should restore console methods after silencing', () => {
|
||||||
|
process.env.MCP_MODE = 'http';
|
||||||
|
|
||||||
|
const originalLog = console.log;
|
||||||
|
const originalError = console.error;
|
||||||
|
|
||||||
|
manager.silence();
|
||||||
|
expect(console.log).not.toBe(originalLog);
|
||||||
|
|
||||||
|
manager.restore();
|
||||||
|
expect(console.log).toBe(originalLog);
|
||||||
|
expect(console.error).toBe(originalError);
|
||||||
|
expect(manager.isActive).toBe(false);
|
||||||
|
expect(process.env.MCP_REQUEST_ACTIVE).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not restore if not silenced', () => {
|
||||||
|
const originalLog = console.log;
|
||||||
|
|
||||||
|
manager.restore(); // Call without silencing first
|
||||||
|
|
||||||
|
expect(console.log).toBe(originalLog);
|
||||||
|
expect(manager.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should restore all console methods', () => {
|
||||||
|
process.env.MCP_MODE = 'http';
|
||||||
|
|
||||||
|
const originalMethods = {
|
||||||
|
log: console.log,
|
||||||
|
error: console.error,
|
||||||
|
warn: console.warn,
|
||||||
|
info: console.info,
|
||||||
|
debug: console.debug,
|
||||||
|
trace: console.trace
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.silence();
|
||||||
|
manager.restore();
|
||||||
|
|
||||||
|
expect(console.log).toBe(originalMethods.log);
|
||||||
|
expect(console.error).toBe(originalMethods.error);
|
||||||
|
expect(console.warn).toBe(originalMethods.warn);
|
||||||
|
expect(console.info).toBe(originalMethods.info);
|
||||||
|
expect(console.debug).toBe(originalMethods.debug);
|
||||||
|
expect(console.trace).toBe(originalMethods.trace);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('wrapOperation method', () => {
|
||||||
|
test('should wrap synchronous operations', async () => {
|
||||||
|
process.env.MCP_MODE = 'http';
|
||||||
|
|
||||||
|
const testValue = 'test-result';
|
||||||
|
const operation = vi.fn(() => testValue);
|
||||||
|
|
||||||
|
const result = await manager.wrapOperation(operation);
|
||||||
|
|
||||||
|
expect(result).toBe(testValue);
|
||||||
|
expect(operation).toHaveBeenCalledOnce();
|
||||||
|
expect(manager.isActive).toBe(false); // Should be restored after operation
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should wrap asynchronous operations', async () => {
|
||||||
|
process.env.MCP_MODE = 'http';
|
||||||
|
|
||||||
|
const testValue = 'async-result';
|
||||||
|
const operation = vi.fn(async () => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10));
|
||||||
|
return testValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await manager.wrapOperation(operation);
|
||||||
|
|
||||||
|
expect(result).toBe(testValue);
|
||||||
|
expect(operation).toHaveBeenCalledOnce();
|
||||||
|
expect(manager.isActive).toBe(false); // Should be restored after operation
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should restore console even if synchronous operation throws', async () => {
|
||||||
|
process.env.MCP_MODE = 'http';
|
||||||
|
|
||||||
|
const error = new Error('test error');
|
||||||
|
const operation = vi.fn(() => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(manager.wrapOperation(operation)).rejects.toThrow('test error');
|
||||||
|
expect(manager.isActive).toBe(false); // Should be restored even after error
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should restore console even if async operation throws', async () => {
|
||||||
|
process.env.MCP_MODE = 'http';
|
||||||
|
|
||||||
|
const error = new Error('async test error');
|
||||||
|
const operation = vi.fn(async () => {
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(manager.wrapOperation(operation)).rejects.toThrow('async test error');
|
||||||
|
expect(manager.isActive).toBe(false); // Should be restored even after error
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle promise rejection properly', async () => {
|
||||||
|
process.env.MCP_MODE = 'http';
|
||||||
|
|
||||||
|
const error = new Error('promise rejection');
|
||||||
|
const operation = vi.fn(() => Promise.reject(error));
|
||||||
|
|
||||||
|
await expect(manager.wrapOperation(operation)).rejects.toThrow('promise rejection');
|
||||||
|
expect(manager.isActive).toBe(false); // Should be restored even after rejection
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isActive getter', () => {
|
||||||
|
test('should return false initially', () => {
|
||||||
|
expect(manager.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return true when silenced', () => {
|
||||||
|
process.env.MCP_MODE = 'http';
|
||||||
|
|
||||||
|
manager.silence();
|
||||||
|
expect(manager.isActive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return false after restore', () => {
|
||||||
|
process.env.MCP_MODE = 'http';
|
||||||
|
|
||||||
|
manager.silence();
|
||||||
|
manager.restore();
|
||||||
|
expect(manager.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Singleton instance', () => {
|
||||||
|
test('should export a singleton instance', () => {
|
||||||
|
expect(consoleManager).toBeInstanceOf(ConsoleManager);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should work with singleton instance', () => {
|
||||||
|
process.env.MCP_MODE = 'http';
|
||||||
|
|
||||||
|
const originalLog = console.log;
|
||||||
|
|
||||||
|
consoleManager.silence();
|
||||||
|
expect(console.log).not.toBe(originalLog);
|
||||||
|
expect(consoleManager.isActive).toBe(true);
|
||||||
|
|
||||||
|
consoleManager.restore();
|
||||||
|
expect(console.log).toBe(originalLog);
|
||||||
|
expect(consoleManager.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
test('should handle undefined MCP_MODE', () => {
|
||||||
|
delete process.env.MCP_MODE;
|
||||||
|
|
||||||
|
const originalLog = console.log;
|
||||||
|
|
||||||
|
manager.silence();
|
||||||
|
expect(console.log).toBe(originalLog);
|
||||||
|
expect(manager.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty MCP_MODE', () => {
|
||||||
|
process.env.MCP_MODE = '' as any;
|
||||||
|
|
||||||
|
const originalLog = console.log;
|
||||||
|
|
||||||
|
manager.silence();
|
||||||
|
expect(console.log).toBe(originalLog);
|
||||||
|
expect(manager.isActive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should silence and restore multiple times', () => {
|
||||||
|
process.env.MCP_MODE = 'http';
|
||||||
|
|
||||||
|
const originalLog = console.log;
|
||||||
|
|
||||||
|
// First cycle
|
||||||
|
manager.silence();
|
||||||
|
expect(manager.isActive).toBe(true);
|
||||||
|
manager.restore();
|
||||||
|
expect(manager.isActive).toBe(false);
|
||||||
|
expect(console.log).toBe(originalLog);
|
||||||
|
|
||||||
|
// Second cycle
|
||||||
|
manager.silence();
|
||||||
|
expect(manager.isActive).toBe(true);
|
||||||
|
manager.restore();
|
||||||
|
expect(manager.isActive).toBe(false);
|
||||||
|
expect(console.log).toBe(originalLog);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
786
tests/unit/utils/fixed-collection-validator.test.ts
Normal file
786
tests/unit/utils/fixed-collection-validator.test.ts
Normal file
@@ -0,0 +1,786 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { FixedCollectionValidator, NodeConfig, NodeConfigValue } from '../../../src/utils/fixed-collection-validator';
|
||||||
|
|
||||||
|
// Type guard helper for tests
|
||||||
|
function isNodeConfig(value: NodeConfig | NodeConfigValue[] | undefined): value is NodeConfig {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FixedCollectionValidator', () => {
|
||||||
|
describe('Core Functionality', () => {
|
||||||
|
test('should return valid for non-susceptible nodes', () => {
|
||||||
|
const result = FixedCollectionValidator.validate('n8n-nodes-base.cron', {
|
||||||
|
triggerTimes: { hour: 10, minute: 30 }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should normalize node types correctly', () => {
|
||||||
|
const nodeTypes = [
|
||||||
|
'n8n-nodes-base.switch',
|
||||||
|
'nodes-base.switch',
|
||||||
|
'@n8n/n8n-nodes-langchain.switch',
|
||||||
|
'SWITCH'
|
||||||
|
];
|
||||||
|
|
||||||
|
nodeTypes.forEach(nodeType => {
|
||||||
|
expect(FixedCollectionValidator.isNodeSusceptible(nodeType)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should get all known patterns', () => {
|
||||||
|
const patterns = FixedCollectionValidator.getAllPatterns();
|
||||||
|
expect(patterns.length).toBeGreaterThan(10); // We have at least 11 patterns
|
||||||
|
expect(patterns.some(p => p.nodeType === 'switch')).toBe(true);
|
||||||
|
expect(patterns.some(p => p.nodeType === 'summarize')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Switch Node Validation', () => {
|
||||||
|
test('should detect invalid nested conditions structure', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
rules: {
|
||||||
|
conditions: {
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
value1: '={{$json.status}}',
|
||||||
|
operation: 'equals',
|
||||||
|
value2: 'active'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('n8n-nodes-base.switch', invalidConfig);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors).toHaveLength(2); // Both rules.conditions and rules.conditions.values match
|
||||||
|
// Check that we found the specific pattern
|
||||||
|
const conditionsValuesError = result.errors.find(e => e.pattern === 'rules.conditions.values');
|
||||||
|
expect(conditionsValuesError).toBeDefined();
|
||||||
|
expect(conditionsValuesError!.message).toContain('propertyValues[itemName] is not iterable');
|
||||||
|
expect(result.autofix).toBeDefined();
|
||||||
|
expect(isNodeConfig(result.autofix)).toBe(true);
|
||||||
|
if (isNodeConfig(result.autofix)) {
|
||||||
|
expect(result.autofix.rules).toBeDefined();
|
||||||
|
expect((result.autofix.rules as any).values).toBeDefined();
|
||||||
|
expect((result.autofix.rules as any).values[0].outputKey).toBe('output1');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should provide correct autofix for switch node', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
rules: {
|
||||||
|
conditions: {
|
||||||
|
values: [
|
||||||
|
{ value1: '={{$json.a}}', operation: 'equals', value2: '1' },
|
||||||
|
{ value1: '={{$json.b}}', operation: 'equals', value2: '2' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('switch', invalidConfig);
|
||||||
|
|
||||||
|
expect(isNodeConfig(result.autofix)).toBe(true);
|
||||||
|
if (isNodeConfig(result.autofix)) {
|
||||||
|
expect((result.autofix.rules as any).values).toHaveLength(2);
|
||||||
|
expect((result.autofix.rules as any).values[0].outputKey).toBe('output1');
|
||||||
|
expect((result.autofix.rules as any).values[1].outputKey).toBe('output2');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('If/Filter Node Validation', () => {
|
||||||
|
test('should detect invalid nested values structure', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
conditions: {
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
value1: '={{$json.age}}',
|
||||||
|
operation: 'largerEqual',
|
||||||
|
value2: 18
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ifResult = FixedCollectionValidator.validate('n8n-nodes-base.if', invalidConfig);
|
||||||
|
const filterResult = FixedCollectionValidator.validate('n8n-nodes-base.filter', invalidConfig);
|
||||||
|
|
||||||
|
expect(ifResult.isValid).toBe(false);
|
||||||
|
expect(ifResult.errors[0].fix).toContain('directly, not nested under "values"');
|
||||||
|
expect(ifResult.autofix).toEqual([
|
||||||
|
{
|
||||||
|
value1: '={{$json.age}}',
|
||||||
|
operation: 'largerEqual',
|
||||||
|
value2: 18
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(filterResult.isValid).toBe(false);
|
||||||
|
expect(filterResult.autofix).toEqual(ifResult.autofix);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('New Nodes Validation', () => {
|
||||||
|
test('should validate Summarize node', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
fieldsToSummarize: {
|
||||||
|
values: {
|
||||||
|
values: [
|
||||||
|
{ field: 'amount', aggregation: 'sum' },
|
||||||
|
{ field: 'count', aggregation: 'count' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('summarize', invalidConfig);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors[0].pattern).toBe('fieldsToSummarize.values.values');
|
||||||
|
expect(result.errors[0].fix).toContain('not nested values.values');
|
||||||
|
expect(isNodeConfig(result.autofix)).toBe(true);
|
||||||
|
if (isNodeConfig(result.autofix)) {
|
||||||
|
expect((result.autofix.fieldsToSummarize as any).values).toHaveLength(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate Compare Datasets node', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
mergeByFields: {
|
||||||
|
values: {
|
||||||
|
values: [
|
||||||
|
{ field1: 'id', field2: 'userId' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('compareDatasets', invalidConfig);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors[0].pattern).toBe('mergeByFields.values.values');
|
||||||
|
expect(isNodeConfig(result.autofix)).toBe(true);
|
||||||
|
if (isNodeConfig(result.autofix)) {
|
||||||
|
expect((result.autofix.mergeByFields as any).values).toHaveLength(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate Sort node', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
sortFieldsUi: {
|
||||||
|
sortField: {
|
||||||
|
values: [
|
||||||
|
{ fieldName: 'date', order: 'descending' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('sort', invalidConfig);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors[0].pattern).toBe('sortFieldsUi.sortField.values');
|
||||||
|
expect(result.errors[0].fix).toContain('not sortField.values');
|
||||||
|
expect(isNodeConfig(result.autofix)).toBe(true);
|
||||||
|
if (isNodeConfig(result.autofix)) {
|
||||||
|
expect((result.autofix.sortFieldsUi as any).sortField).toHaveLength(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate Aggregate node', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
fieldsToAggregate: {
|
||||||
|
fieldToAggregate: {
|
||||||
|
values: [
|
||||||
|
{ fieldToAggregate: 'price', aggregation: 'average' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('aggregate', invalidConfig);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors[0].pattern).toBe('fieldsToAggregate.fieldToAggregate.values');
|
||||||
|
expect(isNodeConfig(result.autofix)).toBe(true);
|
||||||
|
if (isNodeConfig(result.autofix)) {
|
||||||
|
expect((result.autofix.fieldsToAggregate as any).fieldToAggregate).toHaveLength(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate Set node', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
fields: {
|
||||||
|
values: {
|
||||||
|
values: [
|
||||||
|
{ name: 'status', value: 'active' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('set', invalidConfig);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors[0].pattern).toBe('fields.values.values');
|
||||||
|
expect(isNodeConfig(result.autofix)).toBe(true);
|
||||||
|
if (isNodeConfig(result.autofix)) {
|
||||||
|
expect((result.autofix.fields as any).values).toHaveLength(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate HTML node', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
extractionValues: {
|
||||||
|
values: {
|
||||||
|
values: [
|
||||||
|
{ key: 'title', cssSelector: 'h1' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('html', invalidConfig);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors[0].pattern).toBe('extractionValues.values.values');
|
||||||
|
expect(isNodeConfig(result.autofix)).toBe(true);
|
||||||
|
if (isNodeConfig(result.autofix)) {
|
||||||
|
expect((result.autofix.extractionValues as any).values).toHaveLength(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate HTTP Request node', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
body: {
|
||||||
|
parameters: {
|
||||||
|
values: [
|
||||||
|
{ name: 'api_key', value: '123' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('httpRequest', invalidConfig);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors[0].pattern).toBe('body.parameters.values');
|
||||||
|
expect(result.errors[0].fix).toContain('not parameters.values');
|
||||||
|
expect(isNodeConfig(result.autofix)).toBe(true);
|
||||||
|
if (isNodeConfig(result.autofix)) {
|
||||||
|
expect((result.autofix.body as any).parameters).toHaveLength(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should validate Airtable node', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
sort: {
|
||||||
|
sortField: {
|
||||||
|
values: [
|
||||||
|
{ fieldName: 'Created', direction: 'desc' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('airtable', invalidConfig);
|
||||||
|
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors[0].pattern).toBe('sort.sortField.values');
|
||||||
|
expect(isNodeConfig(result.autofix)).toBe(true);
|
||||||
|
if (isNodeConfig(result.autofix)) {
|
||||||
|
expect((result.autofix.sort as any).sortField).toHaveLength(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases', () => {
|
||||||
|
test('should handle empty config', () => {
|
||||||
|
const result = FixedCollectionValidator.validate('switch', {});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle null/undefined properties', () => {
|
||||||
|
const result = FixedCollectionValidator.validate('switch', {
|
||||||
|
rules: null
|
||||||
|
});
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle valid structures', () => {
|
||||||
|
const validSwitch = {
|
||||||
|
rules: {
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
conditions: { value1: '={{$json.x}}', operation: 'equals', value2: 1 },
|
||||||
|
outputKey: 'output1'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('switch', validSwitch);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle deeply nested invalid structures', () => {
|
||||||
|
const deeplyNested = {
|
||||||
|
rules: {
|
||||||
|
conditions: {
|
||||||
|
values: [
|
||||||
|
{
|
||||||
|
value1: '={{$json.deep}}',
|
||||||
|
operation: 'equals',
|
||||||
|
value2: 'nested'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('switch', deeplyNested);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors).toHaveLength(2); // Both patterns match
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Private Method Testing (through public API)', () => {
|
||||||
|
describe('isNodeConfig Type Guard', () => {
|
||||||
|
test('should return true for plain objects', () => {
|
||||||
|
const validConfig = { property: 'value' };
|
||||||
|
const result = FixedCollectionValidator.validate('switch', validConfig);
|
||||||
|
// Type guard is tested indirectly through validation
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle null values correctly', () => {
|
||||||
|
const result = FixedCollectionValidator.validate('switch', null as any);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle undefined values correctly', () => {
|
||||||
|
const result = FixedCollectionValidator.validate('switch', undefined as any);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle arrays correctly', () => {
|
||||||
|
const result = FixedCollectionValidator.validate('switch', [] as any);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle primitive values correctly', () => {
|
||||||
|
const result1 = FixedCollectionValidator.validate('switch', 'string' as any);
|
||||||
|
expect(result1.isValid).toBe(true);
|
||||||
|
|
||||||
|
const result2 = FixedCollectionValidator.validate('switch', 123 as any);
|
||||||
|
expect(result2.isValid).toBe(true);
|
||||||
|
|
||||||
|
const result3 = FixedCollectionValidator.validate('switch', true as any);
|
||||||
|
expect(result3.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNestedValue Testing', () => {
|
||||||
|
test('should handle simple nested paths', () => {
|
||||||
|
const config = {
|
||||||
|
rules: {
|
||||||
|
conditions: {
|
||||||
|
values: [{ test: 'value' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('switch', config);
|
||||||
|
expect(result.isValid).toBe(false); // This tests the nested value extraction
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle non-existent paths gracefully', () => {
|
||||||
|
const config = {
|
||||||
|
rules: {
|
||||||
|
// missing conditions property
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('switch', config);
|
||||||
|
expect(result.isValid).toBe(true); // Should not find invalid structure
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle interrupted paths (null/undefined in middle)', () => {
|
||||||
|
const config = {
|
||||||
|
rules: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('switch', config);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle array interruptions in path', () => {
|
||||||
|
const config = {
|
||||||
|
rules: [1, 2, 3] // array instead of object
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('switch', config);
|
||||||
|
expect(result.isValid).toBe(true); // Should not find the pattern
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Circular Reference Protection', () => {
|
||||||
|
test('should handle circular references in config', () => {
|
||||||
|
const config: any = {
|
||||||
|
rules: {
|
||||||
|
conditions: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Create circular reference
|
||||||
|
config.rules.conditions.circular = config.rules;
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('switch', config);
|
||||||
|
// Should not crash and should detect the pattern (result is false because it finds rules.conditions)
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle self-referencing objects', () => {
|
||||||
|
const config: any = {
|
||||||
|
rules: {}
|
||||||
|
};
|
||||||
|
config.rules.self = config.rules;
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('switch', config);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle deeply nested circular references', () => {
|
||||||
|
const config: any = {
|
||||||
|
rules: {
|
||||||
|
conditions: {
|
||||||
|
values: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
config.rules.conditions.values.back = config;
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('switch', config);
|
||||||
|
// Should detect the problematic pattern: rules.conditions.values exists
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Deep Copying in getAllPatterns', () => {
|
||||||
|
test('should return independent copies of patterns', () => {
|
||||||
|
const patterns1 = FixedCollectionValidator.getAllPatterns();
|
||||||
|
const patterns2 = FixedCollectionValidator.getAllPatterns();
|
||||||
|
|
||||||
|
// Modify one copy
|
||||||
|
patterns1[0].invalidPatterns.push('test.pattern');
|
||||||
|
|
||||||
|
// Other copy should be unaffected
|
||||||
|
expect(patterns2[0].invalidPatterns).not.toContain('test.pattern');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should deep copy invalidPatterns arrays', () => {
|
||||||
|
const patterns = FixedCollectionValidator.getAllPatterns();
|
||||||
|
const switchPattern = patterns.find(p => p.nodeType === 'switch')!;
|
||||||
|
|
||||||
|
expect(switchPattern.invalidPatterns).toBeInstanceOf(Array);
|
||||||
|
expect(switchPattern.invalidPatterns.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Ensure it's a different array instance
|
||||||
|
const originalPatterns = FixedCollectionValidator.getAllPatterns();
|
||||||
|
const originalSwitch = originalPatterns.find(p => p.nodeType === 'switch')!;
|
||||||
|
|
||||||
|
expect(switchPattern.invalidPatterns).not.toBe(originalSwitch.invalidPatterns);
|
||||||
|
expect(switchPattern.invalidPatterns).toEqual(originalSwitch.invalidPatterns);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Enhanced Edge Cases', () => {
|
||||||
|
test('should handle hasOwnProperty edge case', () => {
|
||||||
|
const config = Object.create(null);
|
||||||
|
config.rules = {
|
||||||
|
conditions: {
|
||||||
|
values: [{ test: 'value' }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('switch', config);
|
||||||
|
expect(result.isValid).toBe(false); // Should still detect the pattern
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle prototype pollution attempts', () => {
|
||||||
|
const config = {
|
||||||
|
rules: {
|
||||||
|
conditions: {
|
||||||
|
values: [{ test: 'value' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add prototype property (should be ignored by hasOwnProperty check)
|
||||||
|
(Object.prototype as any).maliciousProperty = 'evil';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = FixedCollectionValidator.validate('switch', config);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors).toHaveLength(2);
|
||||||
|
} finally {
|
||||||
|
delete (Object.prototype as any).maliciousProperty;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle objects with numeric keys', () => {
|
||||||
|
const config = {
|
||||||
|
rules: {
|
||||||
|
'0': {
|
||||||
|
values: [{ test: 'value' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('switch', config);
|
||||||
|
expect(result.isValid).toBe(true); // Should not match 'conditions' pattern
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle very deep nesting without crashing', () => {
|
||||||
|
let deepConfig: any = {};
|
||||||
|
let current = deepConfig;
|
||||||
|
|
||||||
|
// Create 100 levels deep
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
current.next = {};
|
||||||
|
current = current.next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('switch', deepConfig);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Alternative Node Type Formats', () => {
|
||||||
|
test('should handle all node type normalization cases', () => {
|
||||||
|
const testCases = [
|
||||||
|
'n8n-nodes-base.switch',
|
||||||
|
'nodes-base.switch',
|
||||||
|
'@n8n/n8n-nodes-langchain.switch',
|
||||||
|
'SWITCH',
|
||||||
|
'Switch',
|
||||||
|
'sWiTcH'
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.forEach(nodeType => {
|
||||||
|
expect(FixedCollectionValidator.isNodeSusceptible(nodeType)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle empty and invalid node types', () => {
|
||||||
|
expect(FixedCollectionValidator.isNodeSusceptible('')).toBe(false);
|
||||||
|
expect(FixedCollectionValidator.isNodeSusceptible('unknown-node')).toBe(false);
|
||||||
|
expect(FixedCollectionValidator.isNodeSusceptible('n8n-nodes-base.unknown')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Complex Autofix Scenarios', () => {
|
||||||
|
test('should handle switch autofix with non-array values', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
rules: {
|
||||||
|
conditions: {
|
||||||
|
values: { single: 'condition' } // Object instead of array
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('switch', invalidConfig);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(isNodeConfig(result.autofix)).toBe(true);
|
||||||
|
|
||||||
|
if (isNodeConfig(result.autofix)) {
|
||||||
|
const values = (result.autofix.rules as any).values;
|
||||||
|
expect(values).toHaveLength(1);
|
||||||
|
expect(values[0].conditions).toEqual({ single: 'condition' });
|
||||||
|
expect(values[0].outputKey).toBe('output1');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle if/filter autofix with object values', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
conditions: {
|
||||||
|
values: { type: 'single', condition: 'test' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('if', invalidConfig);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.autofix).toEqual({ type: 'single', condition: 'test' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle applyAutofix for if/filter with null values', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
conditions: {
|
||||||
|
values: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pattern = FixedCollectionValidator.getAllPatterns().find(p => p.nodeType === 'if')!;
|
||||||
|
const fixed = FixedCollectionValidator.applyAutofix(invalidConfig, pattern);
|
||||||
|
|
||||||
|
// Should return the original config when values is null
|
||||||
|
expect(fixed).toEqual(invalidConfig);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle applyAutofix for if/filter with undefined values', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
conditions: {
|
||||||
|
values: undefined
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pattern = FixedCollectionValidator.getAllPatterns().find(p => p.nodeType === 'if')!;
|
||||||
|
const fixed = FixedCollectionValidator.applyAutofix(invalidConfig, pattern);
|
||||||
|
|
||||||
|
// Should return the original config when values is undefined
|
||||||
|
expect(fixed).toEqual(invalidConfig);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyAutofix Method', () => {
|
||||||
|
test('should apply autofix correctly for if/filter nodes', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
conditions: {
|
||||||
|
values: [
|
||||||
|
{ value1: '={{$json.test}}', operation: 'equals', value2: 'yes' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pattern = FixedCollectionValidator.getAllPatterns().find(p => p.nodeType === 'if');
|
||||||
|
const fixed = FixedCollectionValidator.applyAutofix(invalidConfig, pattern!);
|
||||||
|
|
||||||
|
expect(fixed).toEqual([
|
||||||
|
{ value1: '={{$json.test}}', operation: 'equals', value2: 'yes' }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return original config for non-if/filter nodes', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
fieldsToSummarize: {
|
||||||
|
values: {
|
||||||
|
values: [{ field: 'test' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pattern = FixedCollectionValidator.getAllPatterns().find(p => p.nodeType === 'summarize');
|
||||||
|
const fixed = FixedCollectionValidator.applyAutofix(invalidConfig, pattern!);
|
||||||
|
|
||||||
|
expect(isNodeConfig(fixed)).toBe(true);
|
||||||
|
if (isNodeConfig(fixed)) {
|
||||||
|
expect((fixed.fieldsToSummarize as any).values).toEqual([{ field: 'test' }]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle filter node applyAutofix edge cases', () => {
|
||||||
|
const invalidConfig = {
|
||||||
|
conditions: {
|
||||||
|
values: 'string-value' // Invalid type
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pattern = FixedCollectionValidator.getAllPatterns().find(p => p.nodeType === 'filter');
|
||||||
|
const fixed = FixedCollectionValidator.applyAutofix(invalidConfig, pattern!);
|
||||||
|
|
||||||
|
// Should return original config when values is not object/array
|
||||||
|
expect(fixed).toEqual(invalidConfig);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Missing Function Coverage Tests', () => {
|
||||||
|
test('should test all generateFixMessage cases', () => {
|
||||||
|
// Test each node type's fix message generation through validation
|
||||||
|
const nodeConfigs = [
|
||||||
|
{ nodeType: 'switch', config: { rules: { conditions: { values: [] } } } },
|
||||||
|
{ nodeType: 'if', config: { conditions: { values: [] } } },
|
||||||
|
{ nodeType: 'filter', config: { conditions: { values: [] } } },
|
||||||
|
{ nodeType: 'summarize', config: { fieldsToSummarize: { values: { values: [] } } } },
|
||||||
|
{ nodeType: 'comparedatasets', config: { mergeByFields: { values: { values: [] } } } },
|
||||||
|
{ nodeType: 'sort', config: { sortFieldsUi: { sortField: { values: [] } } } },
|
||||||
|
{ nodeType: 'aggregate', config: { fieldsToAggregate: { fieldToAggregate: { values: [] } } } },
|
||||||
|
{ nodeType: 'set', config: { fields: { values: { values: [] } } } },
|
||||||
|
{ nodeType: 'html', config: { extractionValues: { values: { values: [] } } } },
|
||||||
|
{ nodeType: 'httprequest', config: { body: { parameters: { values: [] } } } },
|
||||||
|
{ nodeType: 'airtable', config: { sort: { sortField: { values: [] } } } },
|
||||||
|
];
|
||||||
|
|
||||||
|
nodeConfigs.forEach(({ nodeType, config }) => {
|
||||||
|
const result = FixedCollectionValidator.validate(nodeType, config);
|
||||||
|
expect(result.isValid).toBe(false);
|
||||||
|
expect(result.errors.length).toBeGreaterThan(0);
|
||||||
|
expect(result.errors[0].fix).toBeDefined();
|
||||||
|
expect(typeof result.errors[0].fix).toBe('string');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should test default case in generateFixMessage', () => {
|
||||||
|
// Create a custom pattern with unknown nodeType to test default case
|
||||||
|
const mockPattern = {
|
||||||
|
nodeType: 'unknown-node-type',
|
||||||
|
property: 'testProperty',
|
||||||
|
expectedStructure: 'test.structure',
|
||||||
|
invalidPatterns: ['test.invalid.pattern']
|
||||||
|
};
|
||||||
|
|
||||||
|
// We can't directly test the private generateFixMessage method,
|
||||||
|
// but we can test through the validation logic by temporarily adding to KNOWN_PATTERNS
|
||||||
|
// Instead, let's verify the method works by checking error messages contain the expected structure
|
||||||
|
const patterns = FixedCollectionValidator.getAllPatterns();
|
||||||
|
expect(patterns.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Ensure we have patterns that would exercise different fix message paths
|
||||||
|
const switchPattern = patterns.find(p => p.nodeType === 'switch');
|
||||||
|
expect(switchPattern).toBeDefined();
|
||||||
|
expect(switchPattern!.expectedStructure).toBe('rules.values array');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should exercise hasInvalidStructure edge cases', () => {
|
||||||
|
// Test with property that exists but is not at the end of the pattern
|
||||||
|
const config = {
|
||||||
|
rules: {
|
||||||
|
conditions: 'string-value' // Not an object, so traversal should stop
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = FixedCollectionValidator.validate('switch', config);
|
||||||
|
expect(result.isValid).toBe(false); // Should still detect rules.conditions pattern
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should test getNestedValue with complex paths', () => {
|
||||||
|
// Test through hasInvalidStructure which uses getNestedValue
|
||||||
|
const config = {
|
||||||
|
deeply: {
|
||||||
|
nested: {
|
||||||
|
path: {
|
||||||
|
to: {
|
||||||
|
value: 'exists'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// This would exercise the getNestedValue function through hasInvalidStructure
|
||||||
|
const result = FixedCollectionValidator.validate('switch', config);
|
||||||
|
expect(result.isValid).toBe(true); // No matching patterns
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
123
tests/unit/utils/simple-cache-memory-leak-fix.test.ts
Normal file
123
tests/unit/utils/simple-cache-memory-leak-fix.test.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { SimpleCache } from '../../../src/utils/simple-cache';
|
||||||
|
|
||||||
|
describe('SimpleCache Memory Leak Fix', () => {
|
||||||
|
let cache: SimpleCache;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (cache && typeof cache.destroy === 'function') {
|
||||||
|
cache.destroy();
|
||||||
|
}
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should track cleanup timer', () => {
|
||||||
|
cache = new SimpleCache();
|
||||||
|
// Access private property for testing
|
||||||
|
expect((cache as any).cleanupTimer).toBeDefined();
|
||||||
|
expect((cache as any).cleanupTimer).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear timer on destroy', () => {
|
||||||
|
cache = new SimpleCache();
|
||||||
|
const timer = (cache as any).cleanupTimer;
|
||||||
|
|
||||||
|
cache.destroy();
|
||||||
|
|
||||||
|
expect((cache as any).cleanupTimer).toBeNull();
|
||||||
|
// Verify timer was cleared
|
||||||
|
expect(() => clearInterval(timer)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear cache on destroy', () => {
|
||||||
|
cache = new SimpleCache();
|
||||||
|
cache.set('test-key', 'test-value', 300);
|
||||||
|
|
||||||
|
expect(cache.get('test-key')).toBe('test-value');
|
||||||
|
|
||||||
|
cache.destroy();
|
||||||
|
|
||||||
|
expect(cache.get('test-key')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple destroy calls safely', () => {
|
||||||
|
cache = new SimpleCache();
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
cache.destroy();
|
||||||
|
cache.destroy();
|
||||||
|
cache.destroy();
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
expect((cache as any).cleanupTimer).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not create new timers after destroy', () => {
|
||||||
|
cache = new SimpleCache();
|
||||||
|
const originalTimer = (cache as any).cleanupTimer;
|
||||||
|
|
||||||
|
cache.destroy();
|
||||||
|
|
||||||
|
// Try to use the cache after destroy
|
||||||
|
cache.set('key', 'value');
|
||||||
|
cache.get('key');
|
||||||
|
cache.clear();
|
||||||
|
|
||||||
|
// Timer should still be null
|
||||||
|
expect((cache as any).cleanupTimer).toBeNull();
|
||||||
|
expect((cache as any).cleanupTimer).not.toBe(originalTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clean up expired entries periodically', () => {
|
||||||
|
cache = new SimpleCache();
|
||||||
|
|
||||||
|
// Set items with different TTLs
|
||||||
|
cache.set('short', 'value1', 1); // 1 second
|
||||||
|
cache.set('long', 'value2', 300); // 300 seconds
|
||||||
|
|
||||||
|
// Advance time by 2 seconds
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
|
||||||
|
// Advance time to trigger cleanup (60 seconds)
|
||||||
|
vi.advanceTimersByTime(58000);
|
||||||
|
|
||||||
|
// Short-lived item should be gone
|
||||||
|
expect(cache.get('short')).toBeNull();
|
||||||
|
// Long-lived item should still exist
|
||||||
|
expect(cache.get('long')).toBe('value2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent memory leak by clearing timer', () => {
|
||||||
|
const timers: NodeJS.Timeout[] = [];
|
||||||
|
const originalSetInterval = global.setInterval;
|
||||||
|
|
||||||
|
// Mock setInterval to track created timers
|
||||||
|
global.setInterval = vi.fn((callback, delay) => {
|
||||||
|
const timer = originalSetInterval(callback, delay);
|
||||||
|
timers.push(timer);
|
||||||
|
return timer;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create and destroy multiple caches
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const tempCache = new SimpleCache();
|
||||||
|
tempCache.set(`key${i}`, `value${i}`);
|
||||||
|
tempCache.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// All timers should have been cleared
|
||||||
|
expect(timers.length).toBe(5);
|
||||||
|
|
||||||
|
// Restore original setInterval
|
||||||
|
global.setInterval = originalSetInterval;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have destroy method defined', () => {
|
||||||
|
cache = new SimpleCache();
|
||||||
|
expect(typeof cache.destroy).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
411
tests/unit/validation-fixes.test.ts
Normal file
411
tests/unit/validation-fixes.test.ts
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
/**
|
||||||
|
* Test suite for validation system fixes
|
||||||
|
* Covers issues #58, #68, #70, #73
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { WorkflowValidator } from '../../src/services/workflow-validator';
|
||||||
|
import { EnhancedConfigValidator } from '../../src/services/enhanced-config-validator';
|
||||||
|
import { ToolValidation, Validator, ValidationError } from '../../src/utils/validation-schemas';
|
||||||
|
|
||||||
|
describe('Validation System Fixes', () => {
|
||||||
|
let workflowValidator: WorkflowValidator;
|
||||||
|
let mockNodeRepository: any;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Initialize test environment
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
|
||||||
|
// Mock repository for testing
|
||||||
|
mockNodeRepository = {
|
||||||
|
getNode: (nodeType: string) => {
|
||||||
|
if (nodeType === 'nodes-base.webhook' || nodeType === 'n8n-nodes-base.webhook') {
|
||||||
|
return {
|
||||||
|
nodeType: 'nodes-base.webhook',
|
||||||
|
displayName: 'Webhook',
|
||||||
|
properties: [
|
||||||
|
{ name: 'path', required: true, displayName: 'Path' },
|
||||||
|
{ name: 'httpMethod', required: true, displayName: 'HTTP Method' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (nodeType === 'nodes-base.set' || nodeType === 'n8n-nodes-base.set') {
|
||||||
|
return {
|
||||||
|
nodeType: 'nodes-base.set',
|
||||||
|
displayName: 'Set',
|
||||||
|
properties: [
|
||||||
|
{ name: 'values', required: false, displayName: 'Values' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
workflowValidator = new WorkflowValidator(mockNodeRepository, EnhancedConfigValidator);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
// Reset NODE_ENV instead of deleting it
|
||||||
|
delete (process.env as any).NODE_ENV;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Issue #73: validate_node_minimal crashes without input validation', () => {
|
||||||
|
test('should handle empty config in validation schemas', () => {
|
||||||
|
// Test the validation schema handles empty config
|
||||||
|
const result = ToolValidation.validateNodeMinimal({
|
||||||
|
nodeType: 'nodes-base.webhook',
|
||||||
|
config: undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.length).toBeGreaterThan(0);
|
||||||
|
expect(result.errors[0].field).toBe('config');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle null config in validation schemas', () => {
|
||||||
|
const result = ToolValidation.validateNodeMinimal({
|
||||||
|
nodeType: 'nodes-base.webhook',
|
||||||
|
config: null
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.length).toBeGreaterThan(0);
|
||||||
|
expect(result.errors[0].field).toBe('config');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should accept valid config object', () => {
|
||||||
|
const result = ToolValidation.validateNodeMinimal({
|
||||||
|
nodeType: 'nodes-base.webhook',
|
||||||
|
config: { path: '/webhook', httpMethod: 'POST' }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Issue #58: validate_node_operation crashes on nested input', () => {
|
||||||
|
test('should handle invalid nodeType gracefully', () => {
|
||||||
|
expect(() => {
|
||||||
|
EnhancedConfigValidator.validateWithMode(
|
||||||
|
undefined as any,
|
||||||
|
{ resource: 'channel', operation: 'create' },
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
'ai-friendly'
|
||||||
|
);
|
||||||
|
}).toThrow(Error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle null nodeType gracefully', () => {
|
||||||
|
expect(() => {
|
||||||
|
EnhancedConfigValidator.validateWithMode(
|
||||||
|
null as any,
|
||||||
|
{ resource: 'channel', operation: 'create' },
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
'ai-friendly'
|
||||||
|
);
|
||||||
|
}).toThrow(Error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle non-string nodeType gracefully', () => {
|
||||||
|
expect(() => {
|
||||||
|
EnhancedConfigValidator.validateWithMode(
|
||||||
|
{ type: 'nodes-base.slack' } as any,
|
||||||
|
{ resource: 'channel', operation: 'create' },
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
'ai-friendly'
|
||||||
|
);
|
||||||
|
}).toThrow(Error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle valid nodeType properly', () => {
|
||||||
|
const result = EnhancedConfigValidator.validateWithMode(
|
||||||
|
'nodes-base.set',
|
||||||
|
{ values: {} },
|
||||||
|
[],
|
||||||
|
'operation',
|
||||||
|
'ai-friendly'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(typeof result.valid).toBe('boolean');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Issue #70: Profile settings not respected', () => {
|
||||||
|
test('should pass profile parameter to all validation phases', async () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Webhook',
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
position: [100, 200] as [number, number],
|
||||||
|
parameters: { path: '/test', httpMethod: 'POST' },
|
||||||
|
typeVersion: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Set',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [300, 200] as [number, number],
|
||||||
|
parameters: { values: {} },
|
||||||
|
typeVersion: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Webhook': {
|
||||||
|
main: [[{ node: 'Set', type: 'main', index: 0 }]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await workflowValidator.validateWorkflow(workflow, {
|
||||||
|
validateNodes: true,
|
||||||
|
validateConnections: true,
|
||||||
|
validateExpressions: true,
|
||||||
|
profile: 'minimal'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
// In minimal profile, should have fewer warnings/errors - just check it's reasonable
|
||||||
|
expect(result.warnings.length).toBeLessThanOrEqual(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should filter out sticky notes from validation', async () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Webhook',
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
position: [100, 200] as [number, number],
|
||||||
|
parameters: { path: '/test', httpMethod: 'POST' },
|
||||||
|
typeVersion: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Sticky Note',
|
||||||
|
type: 'n8n-nodes-base.stickyNote',
|
||||||
|
position: [300, 100] as [number, number],
|
||||||
|
parameters: { content: 'This is a note' },
|
||||||
|
typeVersion: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await workflowValidator.validateWorkflow(workflow);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.statistics.totalNodes).toBe(1); // Only webhook, sticky note excluded
|
||||||
|
expect(result.statistics.enabledNodes).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow legitimate loops in cycle detection', async () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Manual Trigger',
|
||||||
|
type: 'n8n-nodes-base.manualTrigger',
|
||||||
|
position: [100, 200] as [number, number],
|
||||||
|
parameters: {},
|
||||||
|
typeVersion: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'SplitInBatches',
|
||||||
|
type: 'n8n-nodes-base.splitInBatches',
|
||||||
|
position: [300, 200] as [number, number],
|
||||||
|
parameters: { batchSize: 1 },
|
||||||
|
typeVersion: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Set',
|
||||||
|
type: 'n8n-nodes-base.set',
|
||||||
|
position: [500, 200] as [number, number],
|
||||||
|
parameters: { values: {} },
|
||||||
|
typeVersion: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Manual Trigger': {
|
||||||
|
main: [[{ node: 'SplitInBatches', type: 'main', index: 0 }]]
|
||||||
|
},
|
||||||
|
'SplitInBatches': {
|
||||||
|
main: [
|
||||||
|
[{ node: 'Set', type: 'main', index: 0 }], // Done output
|
||||||
|
[{ node: 'Set', type: 'main', index: 0 }] // Loop output
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'Set': {
|
||||||
|
main: [[{ node: 'SplitInBatches', type: 'main', index: 0 }]] // Loop back
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await workflowValidator.validateWorkflow(workflow);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
// Should not report cycle error for legitimate SplitInBatches loop
|
||||||
|
const cycleErrors = result.errors.filter(e => e.message.includes('cycle'));
|
||||||
|
expect(cycleErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Issue #68: Better error recovery suggestions', () => {
|
||||||
|
test('should provide recovery suggestions for invalid node types', async () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Invalid Node',
|
||||||
|
type: 'invalid-node-type',
|
||||||
|
position: [100, 200] as [number, number],
|
||||||
|
parameters: {},
|
||||||
|
typeVersion: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await workflowValidator.validateWorkflow(workflow);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.suggestions.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Should contain recovery suggestions
|
||||||
|
const recoveryStarted = result.suggestions.some(s => s.includes('🔧 RECOVERY'));
|
||||||
|
expect(recoveryStarted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should provide recovery suggestions for connection errors', async () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Webhook',
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
position: [100, 200] as [number, number],
|
||||||
|
parameters: { path: '/test', httpMethod: 'POST' },
|
||||||
|
typeVersion: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Webhook': {
|
||||||
|
main: [[{ node: 'NonExistentNode', type: 'main', index: 0 }]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await workflowValidator.validateWorkflow(workflow);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.suggestions.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Should contain connection recovery suggestions
|
||||||
|
const connectionRecovery = result.suggestions.some(s =>
|
||||||
|
s.includes('Connection errors detected') || s.includes('connection')
|
||||||
|
);
|
||||||
|
expect(connectionRecovery).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should provide workflow for multiple errors', async () => {
|
||||||
|
const workflow = {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Invalid Node 1',
|
||||||
|
type: 'invalid-type-1',
|
||||||
|
position: [100, 200] as [number, number],
|
||||||
|
parameters: {}
|
||||||
|
// Missing typeVersion
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Invalid Node 2',
|
||||||
|
type: 'invalid-type-2',
|
||||||
|
position: [300, 200] as [number, number],
|
||||||
|
parameters: {}
|
||||||
|
// Missing typeVersion
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Invalid Node 3',
|
||||||
|
type: 'invalid-type-3',
|
||||||
|
position: [500, 200] as [number, number],
|
||||||
|
parameters: {}
|
||||||
|
// Missing typeVersion
|
||||||
|
}
|
||||||
|
],
|
||||||
|
connections: {
|
||||||
|
'Invalid Node 1': {
|
||||||
|
main: [[{ node: 'NonExistent', type: 'main', index: 0 }]]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await workflowValidator.validateWorkflow(workflow);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.errors.length).toBeGreaterThan(3);
|
||||||
|
|
||||||
|
// Should provide step-by-step recovery workflow
|
||||||
|
const workflowSuggestion = result.suggestions.some(s =>
|
||||||
|
s.includes('SUGGESTED WORKFLOW') && s.includes('Too many errors detected')
|
||||||
|
);
|
||||||
|
expect(workflowSuggestion).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Enhanced Input Validation', () => {
|
||||||
|
test('should validate tool parameters with schemas', () => {
|
||||||
|
// Test validate_node_operation parameters
|
||||||
|
const validationResult = ToolValidation.validateNodeOperation({
|
||||||
|
nodeType: 'nodes-base.webhook',
|
||||||
|
config: { path: '/test' },
|
||||||
|
profile: 'ai-friendly'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validationResult.valid).toBe(true);
|
||||||
|
expect(validationResult.errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should reject invalid parameters', () => {
|
||||||
|
const validationResult = ToolValidation.validateNodeOperation({
|
||||||
|
nodeType: 123, // Invalid type
|
||||||
|
config: 'not an object', // Invalid type
|
||||||
|
profile: 'invalid-profile' // Invalid enum value
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validationResult.valid).toBe(false);
|
||||||
|
expect(validationResult.errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should format validation errors properly', () => {
|
||||||
|
const validationResult = ToolValidation.validateNodeOperation({
|
||||||
|
nodeType: null,
|
||||||
|
config: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorMessage = Validator.formatErrors(validationResult, 'validate_node_operation');
|
||||||
|
|
||||||
|
expect(errorMessage).toContain('validate_node_operation: Validation failed:');
|
||||||
|
expect(errorMessage).toContain('nodeType');
|
||||||
|
expect(errorMessage).toContain('config');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user