mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-27 20:53:08 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e4a9d520d | ||
|
|
fb2d306dc3 | ||
|
|
6be9ffa53e | ||
|
|
de2abaf89d | ||
|
|
07bd1d4cc2 |
176
.github/workflows/benchmark-pr.yml
vendored
176
.github/workflows/benchmark-pr.yml
vendored
@@ -1,176 +0,0 @@
|
||||
name: Benchmark PR Comparison
|
||||
on:
|
||||
pull_request:
|
||||
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:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
benchmark-comparison:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
# Run benchmarks on current branch
|
||||
- name: Run current benchmarks
|
||||
run: npm run benchmark:ci
|
||||
|
||||
- name: Save current results
|
||||
run: cp benchmark-results.json benchmark-current.json
|
||||
|
||||
# Checkout and run benchmarks on base branch
|
||||
- name: Checkout base branch
|
||||
run: |
|
||||
git checkout ${{ github.event.pull_request.base.sha }}
|
||||
git status
|
||||
|
||||
- name: Install base dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run baseline benchmarks
|
||||
run: npm run benchmark:ci
|
||||
continue-on-error: true
|
||||
|
||||
- name: Save baseline results
|
||||
run: |
|
||||
if [ -f benchmark-results.json ]; then
|
||||
cp benchmark-results.json benchmark-baseline.json
|
||||
else
|
||||
echo '{"files":[]}' > benchmark-baseline.json
|
||||
fi
|
||||
|
||||
# Compare results
|
||||
- name: Checkout PR branch again
|
||||
run: git checkout ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Compare benchmarks
|
||||
id: compare
|
||||
run: |
|
||||
node scripts/compare-benchmarks.js benchmark-current.json benchmark-baseline.json || echo "REGRESSION=true" >> $GITHUB_OUTPUT
|
||||
|
||||
# Upload comparison artifacts
|
||||
- name: Upload benchmark comparison
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: benchmark-comparison-${{ github.run_number }}
|
||||
path: |
|
||||
benchmark-current.json
|
||||
benchmark-baseline.json
|
||||
benchmark-comparison.json
|
||||
benchmark-comparison.md
|
||||
retention-days: 30
|
||||
|
||||
# Post comparison to PR
|
||||
- name: Post benchmark comparison to PR
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
continue-on-error: true
|
||||
with:
|
||||
script: |
|
||||
try {
|
||||
const fs = require('fs');
|
||||
let comment = '## ⚡ Benchmark Comparison\n\n';
|
||||
|
||||
try {
|
||||
if (fs.existsSync('benchmark-comparison.md')) {
|
||||
const comparison = fs.readFileSync('benchmark-comparison.md', 'utf8');
|
||||
comment += comparison;
|
||||
} else {
|
||||
comment += 'Benchmark comparison could not be generated.';
|
||||
}
|
||||
} catch (error) {
|
||||
comment += `Error reading benchmark comparison: ${error.message}`;
|
||||
}
|
||||
|
||||
comment += '\n\n---\n';
|
||||
comment += `*[View full benchmark results](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})*`;
|
||||
|
||||
// Find existing comment
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const botComment = comments.find(comment =>
|
||||
comment.user.type === 'Bot' &&
|
||||
comment.body.includes('## ⚡ Benchmark Comparison')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
body: comment
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
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
|
||||
- name: Set benchmark status
|
||||
if: always()
|
||||
uses: actions/github-script@v7
|
||||
continue-on-error: true
|
||||
with:
|
||||
script: |
|
||||
try {
|
||||
const hasRegression = '${{ steps.compare.outputs.REGRESSION }}' === 'true';
|
||||
const state = hasRegression ? 'failure' : 'success';
|
||||
const description = hasRegression
|
||||
? 'Performance regressions detected'
|
||||
: 'No performance regressions';
|
||||
|
||||
await github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
sha: context.sha,
|
||||
state: state,
|
||||
target_url: `https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}`,
|
||||
description: description,
|
||||
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.');
|
||||
}
|
||||
214
.github/workflows/benchmark.yml
vendored
214
.github/workflows/benchmark.yml
vendored
@@ -1,214 +0,0 @@
|
||||
name: Performance Benchmarks
|
||||
|
||||
on:
|
||||
push:
|
||||
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:
|
||||
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:
|
||||
|
||||
permissions:
|
||||
# For PR comments
|
||||
pull-requests: write
|
||||
# For pushing to gh-pages branch
|
||||
contents: write
|
||||
# For deployment to GitHub Pages
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
benchmark:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Fetch all history for proper benchmark comparison
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build project
|
||||
run: npm run build
|
||||
|
||||
- name: Run benchmarks
|
||||
run: npm run benchmark:ci
|
||||
|
||||
- name: Format benchmark results
|
||||
run: node scripts/format-benchmark-results.js
|
||||
|
||||
- name: Upload benchmark artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: benchmark-results
|
||||
path: |
|
||||
benchmark-results.json
|
||||
benchmark-results-formatted.json
|
||||
benchmark-summary.json
|
||||
|
||||
# Ensure gh-pages branch exists
|
||||
- name: Check and create gh-pages branch
|
||||
run: |
|
||||
git fetch origin gh-pages:gh-pages 2>/dev/null || {
|
||||
echo "gh-pages branch doesn't exist. Creating it..."
|
||||
git checkout --orphan gh-pages
|
||||
git rm -rf .
|
||||
echo "# Benchmark Results" > README.md
|
||||
git add README.md
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git commit -m "Initial gh-pages commit"
|
||||
git push origin gh-pages
|
||||
git checkout ${{ github.ref_name }}
|
||||
}
|
||||
|
||||
# Clean up workspace before benchmark action
|
||||
- name: Clean workspace
|
||||
run: |
|
||||
git add -A
|
||||
git stash || true
|
||||
|
||||
# Store benchmark results and compare
|
||||
- name: Store benchmark result
|
||||
uses: benchmark-action/github-action-benchmark@v1
|
||||
continue-on-error: true
|
||||
id: benchmark
|
||||
with:
|
||||
name: n8n-mcp Benchmarks
|
||||
tool: 'customSmallerIsBetter'
|
||||
output-file-path: benchmark-results-formatted.json
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
auto-push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
# Where to store benchmark data
|
||||
benchmark-data-dir-path: 'benchmarks'
|
||||
# Alert when performance regresses by 10%
|
||||
alert-threshold: '110%'
|
||||
# Comment on PR when regression is detected
|
||||
comment-on-alert: true
|
||||
alert-comment-cc-users: '@czlonkowski'
|
||||
# Summary always
|
||||
summary-always: true
|
||||
# Max number of data points to retain
|
||||
max-items-in-chart: 50
|
||||
fail-on-alert: false
|
||||
|
||||
# Comment on PR with benchmark results
|
||||
- name: Comment PR with results
|
||||
uses: actions/github-script@v7
|
||||
if: github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const summary = JSON.parse(fs.readFileSync('benchmark-summary.json', 'utf8'));
|
||||
|
||||
// Format results for PR comment
|
||||
let comment = '## 📊 Performance Benchmark Results\n\n';
|
||||
comment += `🕐 Run at: ${new Date(summary.timestamp).toLocaleString()}\n\n`;
|
||||
comment += '| Benchmark | Time | Ops/sec | Range |\n';
|
||||
comment += '|-----------|------|---------|-------|\n';
|
||||
|
||||
// Group benchmarks by category
|
||||
const categories = {};
|
||||
for (const benchmark of summary.benchmarks) {
|
||||
const [category, ...nameParts] = benchmark.name.split(' - ');
|
||||
if (!categories[category]) categories[category] = [];
|
||||
categories[category].push({
|
||||
...benchmark,
|
||||
shortName: nameParts.join(' - ')
|
||||
});
|
||||
}
|
||||
|
||||
// Display by category
|
||||
for (const [category, benchmarks] of Object.entries(categories)) {
|
||||
comment += `\n### ${category}\n`;
|
||||
for (const benchmark of benchmarks) {
|
||||
comment += `| ${benchmark.shortName} | ${benchmark.time} | ${benchmark.opsPerSec} | ${benchmark.range} |\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add comparison link
|
||||
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';
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
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:
|
||||
needs: benchmark
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: gh-pages
|
||||
continue-on-error: true
|
||||
|
||||
# If gh-pages checkout failed, create a minimal structure
|
||||
- name: Ensure gh-pages content exists
|
||||
run: |
|
||||
if [ ! -f "index.html" ]; then
|
||||
echo "Creating minimal gh-pages structure..."
|
||||
mkdir -p benchmarks
|
||||
echo '<!DOCTYPE html><html><head><title>n8n-mcp Benchmarks</title></head><body><h1>n8n-mcp Benchmarks</h1><p>Benchmark data will appear here after the first run.</p></body></html>' > index.html
|
||||
fi
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: '.'
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
6
.github/workflows/dependency-check.yml
vendored
6
.github/workflows/dependency-check.yml
vendored
@@ -77,15 +77,15 @@ jobs:
|
||||
echo "Zod version: $ZOD_VERSION"
|
||||
echo ""
|
||||
|
||||
# Check MCP SDK version - must be exactly 1.27.1
|
||||
# Check MCP SDK version - must be exactly 1.28.0
|
||||
if [[ "$SDK_VERSION" == "not found" ]]; then
|
||||
echo "❌ FAILED: Could not determine MCP SDK version!"
|
||||
echo " The dependency may not have been installed correctly."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$SDK_VERSION" != "1.27.1" ]]; then
|
||||
if [[ "$SDK_VERSION" != "1.28.0" ]]; then
|
||||
echo "❌ FAILED: MCP SDK version mismatch!"
|
||||
echo " Expected: 1.27.1"
|
||||
echo " Expected: 1.28.0"
|
||||
echo " Got: $SDK_VERSION"
|
||||
echo ""
|
||||
echo "This can cause runtime errors. See issues #440, #444, #446, #447, #450"
|
||||
|
||||
8
.github/workflows/docker-build-fast.yml
vendored
8
.github/workflows/docker-build-fast.yml
vendored
@@ -29,9 +29,15 @@ jobs:
|
||||
with:
|
||||
lfs: true
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
|
||||
6
.github/workflows/docker-build-n8n.yml
vendored
6
.github/workflows/docker-build-n8n.yml
vendored
@@ -55,6 +55,12 @@ jobs:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
||||
22
.github/workflows/docker-build.yml
vendored
22
.github/workflows/docker-build.yml
vendored
@@ -71,13 +71,19 @@ jobs:
|
||||
"
|
||||
echo "✅ Synced package.runtime.json to version $VERSION"
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
@@ -85,7 +91,7 @@ jobs:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
@@ -173,13 +179,19 @@ jobs:
|
||||
"
|
||||
echo "✅ Synced package.runtime.json to version $VERSION"
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
|
||||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -441,12 +441,18 @@ jobs:
|
||||
"
|
||||
echo "✅ Synced package.runtime.json to version $VERSION"
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- 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:
|
||||
|
||||
102
.github/workflows/test.yml
vendored
102
.github/workflows/test.yml
vendored
@@ -133,23 +133,6 @@ jobs:
|
||||
- name: Run type checking
|
||||
run: npm run typecheck
|
||||
|
||||
# Run benchmarks
|
||||
- name: Run benchmarks
|
||||
id: benchmarks
|
||||
run: npm run benchmark:ci
|
||||
continue-on-error: true
|
||||
|
||||
# Upload benchmark results
|
||||
- name: Upload benchmark results
|
||||
if: always() && steps.benchmarks.outcome != 'skipped'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: benchmark-results-${{ github.run_number }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
benchmark-results.json
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
|
||||
# Create test report comment for PRs
|
||||
- name: Create test report comment
|
||||
if: github.event_name == 'pull_request' && always()
|
||||
@@ -222,7 +205,6 @@ jobs:
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- [Test Results](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- [Coverage Report](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- [Benchmark Results](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Store test metadata
|
||||
- name: Store test metadata
|
||||
@@ -252,24 +234,21 @@ jobs:
|
||||
path: test-metadata.json
|
||||
retention-days: 30
|
||||
|
||||
# Separate job to process and publish test results
|
||||
# Publish test results as checks
|
||||
publish-results:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Download all artifacts
|
||||
- name: Download all artifacts
|
||||
|
||||
- name: Download test results
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
# Publish test results as checks
|
||||
|
||||
- name: Publish test results
|
||||
uses: dorny/test-reporter@v1
|
||||
if: always()
|
||||
@@ -279,75 +258,4 @@ jobs:
|
||||
path: 'artifacts/test-results-*/test-results/junit.xml'
|
||||
reporter: java-junit
|
||||
fail-on-error: false
|
||||
fail-on-empty: false
|
||||
|
||||
# Create a combined artifact with all results
|
||||
- name: Create combined results artifact
|
||||
if: always()
|
||||
run: |
|
||||
mkdir -p combined-results
|
||||
cp -r artifacts/* combined-results/ 2>/dev/null || true
|
||||
|
||||
# Create index file
|
||||
cat > combined-results/index.html << 'EOF'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>n8n-mcp Test Results</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; }
|
||||
h1 { color: #333; }
|
||||
.section { margin: 20px 0; padding: 20px; border: 1px solid #ddd; border-radius: 5px; }
|
||||
a { color: #0066cc; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>n8n-mcp Test Results</h1>
|
||||
<div class="section">
|
||||
<h2>Test Reports</h2>
|
||||
<ul>
|
||||
<li><a href="test-results-${{ github.run_number }}-${{ github.run_attempt }}/test-reports/report.html">📊 Detailed HTML Report</a></li>
|
||||
<li><a href="test-results-${{ github.run_number }}-${{ github.run_attempt }}/test-results/html/index.html">📈 Vitest HTML Report</a></li>
|
||||
<li><a href="test-results-${{ github.run_number }}-${{ github.run_attempt }}/test-reports/report.md">📄 Markdown Report</a></li>
|
||||
<li><a href="test-results-${{ github.run_number }}-${{ github.run_attempt }}/test-summary.md">📝 PR Summary</a></li>
|
||||
<li><a href="test-results-${{ github.run_number }}-${{ github.run_attempt }}/test-results/junit.xml">🔧 JUnit XML</a></li>
|
||||
<li><a href="test-results-${{ github.run_number }}-${{ github.run_attempt }}/test-results/results.json">🔢 JSON Results</a></li>
|
||||
<li><a href="test-results-${{ github.run_number }}-${{ github.run_attempt }}/test-reports/report.json">📊 Full JSON Report</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Coverage Reports</h2>
|
||||
<ul>
|
||||
<li><a href="coverage-${{ github.run_number }}-${{ github.run_attempt }}/html/index.html">HTML Coverage Report</a></li>
|
||||
<li><a href="coverage-${{ github.run_number }}-${{ github.run_attempt }}/lcov.info">LCOV Report</a></li>
|
||||
<li><a href="coverage-${{ github.run_number }}-${{ github.run_attempt }}/coverage-summary.json">Coverage Summary JSON</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Benchmark Results</h2>
|
||||
<ul>
|
||||
<li><a href="benchmark-results-${{ github.run_number }}-${{ github.run_attempt }}/benchmark-results.json">Benchmark Results JSON</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Metadata</h2>
|
||||
<ul>
|
||||
<li><a href="test-metadata-${{ github.run_number }}-${{ github.run_attempt }}/test-metadata.json">Test Run Metadata</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="section">
|
||||
<p><em>Generated at $(date -u +%Y-%m-%dT%H:%M:%SZ)</em></p>
|
||||
<p><em>Run: #${{ github.run_number }} | SHA: ${{ github.sha }}</em></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
|
||||
- name: Upload combined results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: all-test-results-${{ github.run_number }}
|
||||
path: combined-results/
|
||||
retention-days: 90
|
||||
fail-on-empty: false
|
||||
50
CHANGELOG.md
50
CHANGELOG.md
@@ -7,6 +7,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.41.3] - 2026-03-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Session timeout default too low** (Issue #626): Raised `SESSION_TIMEOUT_MINUTES` default from 5 to 30 minutes. The 5-minute default caused sessions to expire mid-operation during complex multi-step workflows (validate → get structure → patch → validate), forcing users to retry. Configurable via environment variable.
|
||||
|
||||
- **Operations array received as string from VS Code** (Issue #600): Added `z.preprocess` JSON string parsing to the `operations` parameter in `n8n_update_partial_workflow`. The VS Code MCP extension serializes arrays as JSON strings — the Zod schema now transparently parses them before validation.
|
||||
|
||||
- **`undefined` values rejected in MCP tool calls from VS Code** (Issue #611): Strip explicit `undefined` values from tool arguments before Zod validation. VS Code sends `undefined` as a value which Zod's `.optional()` rejects (it expects the field to be missing, not present-but-undefined).
|
||||
|
||||
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.41.2] - 2026-03-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **MCP initialization floods Claude Desktop with JSON parse errors** (Issues #628, #627, #567): Intercept `process.stdout.write` in stdio mode to redirect non-JSON-RPC output to stderr. Console method suppression alone was insufficient — native modules (better-sqlite3), n8n packages, and third-party code can call `process.stdout.write()` directly, corrupting the JSON-RPC stream. Only writes containing valid JSON-RPC messages (`{"jsonrpc":...}`) are now allowed through stdout; everything else is redirected to stderr. This fixes the flood of "Unexpected token is not valid JSON" warnings on every new chat in Claude Desktop, including leaked `refCount`, `dbPath`, `clientVersion`, `protocolVersion`, and other debug strings.
|
||||
|
||||
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.41.1] - 2026-03-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- **If node operators silently fail at runtime** (Issue #665): Replaced incorrect operator names `isNotEmpty`/`isEmpty` with `notEmpty`/`empty` across all validators, sanitizer, documentation, and error messages. n8n's execution engine does not recognize `isNotEmpty`/`isEmpty` — unknown operators silently return `false`, causing If/Switch conditions to always take the wrong branch. Added auto-correction in the sanitizer so existing workflows using legacy names are fixed on update.
|
||||
|
||||
- **`addConnection` creates broken connections with `type: "0"`** (Issue #659): Fixed two edge cases where numeric `targetInput` or `sourceOutput` values leaked into connection objects as `"type": "0"` instead of `"type": "main"`. Numeric `targetInput` values are now remapped to `"main"`, and the `sourceOutput` remapping guard was relaxed to handle redundant `sourceOutput: 0` + `sourceIndex: 0` combinations. Also resolves Issue #653 (dangling connections after `removeNode`) which was caused by malformed connections from this bug.
|
||||
|
||||
- **`__patch_find_replace` corrupts Code node jsCode** (Issue #642): Implemented the `__patch_find_replace` feature for surgical string edits in `updateNode` operations. Previously, passing `{"parameters.jsCode": {"__patch_find_replace": [...]}}` stored the patch object literally as jsCode, producing `[object Object]` at runtime. The feature now reads the current string value, applies each `{find, replace}` entry sequentially, and writes back the modified string. Includes validation for patch format, target property existence, and string type.
|
||||
|
||||
### Improved
|
||||
|
||||
- Extracted `OPERATOR_CORRECTIONS` and `UNARY_OPERATORS` to module-level constants for better performance and single source of truth
|
||||
- Added `exists`/`notExists` to unary operator lists for consistency across sanitizer and validator
|
||||
- Fixed recovery guidance referencing non-existent `validate_node_operation` tool (now `validate_node`)
|
||||
|
||||
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.41.0] - 2026-03-25
|
||||
|
||||
### Changed
|
||||
|
||||
- **Updated n8n dependencies**: n8n 2.12.3 → 2.13.3, n8n-core 2.12.0 → 2.13.1, n8n-workflow 2.12.0 → 2.13.1, @n8n/n8n-nodes-langchain 2.12.0 → 2.13.1
|
||||
- **Rebuilt node database**: 1,396 nodes (812 from n8n-nodes-base/langchain + 584 community: 516 verified + 68 npm)
|
||||
- **Refreshed community nodes**: 584 total (up from 430), with 581 AI-generated documentation summaries
|
||||
- **Improved documentation generator**: Strip `<think>` tags from thinking-model responses; use raw fetch for vLLM `chat_template_kwargs` support
|
||||
- **Incremental community node updates**: `fetch:community` now upserts by default, preserving existing READMEs and AI summaries. Use `--rebuild` for clean slate
|
||||
|
||||
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.40.5] - 2026-03-22
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
# n8n Update Process - Quick Reference
|
||||
|
||||
## ⚡ Recommended Fast Workflow (2025-11-04)
|
||||
## ⚡ Recommended Fast Workflow (2026-03-25)
|
||||
|
||||
**CRITICAL FIRST STEP**: Check existing releases to avoid version conflicts!
|
||||
|
||||
**IMPORTANT: Community nodes are preserved incrementally!**
|
||||
- `npm run update:n8n` rebuilds the base node DB (wipes community nodes temporarily)
|
||||
- Community nodes must be backed up BEFORE and restored AFTER the base rebuild
|
||||
- `npm run fetch:community` now upserts by default (preserves READMEs + AI summaries)
|
||||
- `npm run generate:docs:incremental` only processes nodes missing docs
|
||||
- Use `generate:docs:readme-only` first, then `generate:docs:summary-only` with a local LLM
|
||||
|
||||
```bash
|
||||
# 1. CHECK EXISTING RELEASES FIRST (prevents version conflicts!)
|
||||
gh release list | head -5
|
||||
@@ -15,14 +22,29 @@ git checkout main && git pull
|
||||
# 3. Check for updates (dry run)
|
||||
npm run update:n8n:check
|
||||
|
||||
# 4. Run update and skip tests (we'll test in CI)
|
||||
# 4. Back up community nodes BEFORE update (update:n8n rebuilds base DB!)
|
||||
sqlite3 data/nodes.db ".mode insert nodes" "SELECT * FROM nodes WHERE is_community = 1;" > /tmp/n8n_community_backup.sql
|
||||
|
||||
# 5. Run update and skip tests (we'll test in CI)
|
||||
yes y | npm run update:n8n
|
||||
|
||||
# 5. Refresh community nodes (standard practice!)
|
||||
npm run fetch:community
|
||||
npm run generate:docs
|
||||
# 6. Restore community nodes after rebuild
|
||||
sqlite3 data/nodes.db < /tmp/n8n_community_backup.sql
|
||||
|
||||
# 6. Create feature branch
|
||||
# 7. Refresh community nodes (upserts - preserves existing READMEs + AI summaries!)
|
||||
npm run fetch:community
|
||||
# NOTE: Default mode is now "upsert" - no deletion. Use --rebuild for clean slate.
|
||||
|
||||
# 8. Generate docs incrementally (only for new/missing nodes)
|
||||
npm run generate:docs:readme-only # Fetch READMEs from npm (no LLM needed)
|
||||
# Then with a local LLM server running (LM Studio, vLLM, Ollama):
|
||||
N8N_MCP_LLM_BASE_URL="http://YOUR_SERVER:PORT/v1" \
|
||||
N8N_MCP_LLM_MODEL="your-model-name" \
|
||||
node dist/scripts/generate-community-docs.js --summary-only --skip-existing-summary --llm-concurrency=11
|
||||
# For vLLM with thinking models, the code auto-sends chat_template_kwargs: {enable_thinking: false}
|
||||
# Context length needed: 8K minimum (README truncated to 6000 chars, output max 2000 tokens)
|
||||
|
||||
# 9. Create feature branch
|
||||
git checkout -b update/n8n-X.X.X
|
||||
|
||||
# 7. Update version in package.json (must be HIGHER than latest release!)
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
[](https://www.npmjs.com/package/n8n-mcp)
|
||||
[](https://codecov.io/gh/czlonkowski/n8n-mcp)
|
||||
[](https://github.com/czlonkowski/n8n-mcp/actions)
|
||||
[](https://github.com/n8n-io/n8n)
|
||||
[](https://github.com/n8n-io/n8n)
|
||||
[](https://github.com/czlonkowski/n8n-mcp/pkgs/container/n8n-mcp)
|
||||
[](https://railway.com/deploy/n8n-mcp?referralCode=n8n-mcp)
|
||||
|
||||
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 1,239 workflow automation nodes (809 core + 430 community).
|
||||
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 1,396 workflow automation nodes (812 core + 584 community).
|
||||
|
||||
## Overview
|
||||
|
||||
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:
|
||||
|
||||
- 📚 **1,084 n8n nodes** - 537 core nodes + 547 community nodes (301 verified)
|
||||
- 📚 **1,396 n8n nodes** - 812 core nodes + 584 community nodes (516 verified)
|
||||
- 🔧 **Node properties** - 99% coverage with detailed schemas
|
||||
- ⚡ **Node operations** - 63.6% coverage of available actions
|
||||
- 📄 **Documentation** - 87% coverage from official n8n docs (including AI nodes)
|
||||
|
||||
BIN
data/nodes.db
BIN
data/nodes.db
Binary file not shown.
2
dist/database/node-repository.d.ts.map
vendored
2
dist/database/node-repository.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"node-repository.d.ts","sourceRoot":"","sources":["../../src/database/node-repository.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"node-repository.d.ts","sourceRoot":"","sources":["../../src/database/node-repository.ts"],"names":[],"mappings":"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"}
|
||||
8
dist/database/node-repository.js
vendored
8
dist/database/node-repository.js
vendored
@@ -12,6 +12,7 @@ class NodeRepository {
|
||||
this.db = dbOrService;
|
||||
}
|
||||
saveNode(node) {
|
||||
const existing = this.db.prepare('SELECT npm_readme, ai_documentation_summary, ai_summary_generated_at FROM nodes WHERE node_type = ?').get(node.nodeType);
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT OR REPLACE INTO nodes (
|
||||
node_type, package_name, display_name, description,
|
||||
@@ -21,10 +22,11 @@ class NodeRepository {
|
||||
properties_schema, operations, credentials_required,
|
||||
outputs, output_names,
|
||||
is_community, is_verified, author_name, author_github_url,
|
||||
npm_package_name, npm_version, npm_downloads, community_fetched_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
npm_package_name, npm_version, npm_downloads, community_fetched_at,
|
||||
npm_readme, ai_documentation_summary, ai_summary_generated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(node.nodeType, node.packageName, node.displayName, node.description, node.category, node.style, node.isAITool ? 1 : 0, node.isTrigger ? 1 : 0, node.isWebhook ? 1 : 0, node.isVersioned ? 1 : 0, node.isToolVariant ? 1 : 0, node.toolVariantOf || null, node.hasToolVariant ? 1 : 0, node.version, node.documentation || null, JSON.stringify(node.properties, null, 2), JSON.stringify(node.operations, 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, node.isCommunity ? 1 : 0, node.isVerified ? 1 : 0, node.authorName || null, node.authorGithubUrl || null, node.npmPackageName || null, node.npmVersion || null, node.npmDownloads || 0, node.communityFetchedAt || null);
|
||||
stmt.run(node.nodeType, node.packageName, node.displayName, node.description, node.category, node.style, node.isAITool ? 1 : 0, node.isTrigger ? 1 : 0, node.isWebhook ? 1 : 0, node.isVersioned ? 1 : 0, node.isToolVariant ? 1 : 0, node.toolVariantOf || null, node.hasToolVariant ? 1 : 0, node.version, node.documentation || null, JSON.stringify(node.properties, null, 2), JSON.stringify(node.operations, 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, node.isCommunity ? 1 : 0, node.isVerified ? 1 : 0, node.authorName || null, node.authorGithubUrl || null, node.npmPackageName || null, node.npmVersion || null, node.npmDownloads || 0, node.communityFetchedAt || null, existing?.npm_readme || null, existing?.ai_documentation_summary || null, existing?.ai_summary_generated_at || null);
|
||||
}
|
||||
getNode(nodeType) {
|
||||
const normalizedType = node_type_normalizer_1.NodeTypeNormalizer.normalizeToFullForm(nodeType);
|
||||
|
||||
2
dist/database/node-repository.js.map
vendored
2
dist/database/node-repository.js.map
vendored
File diff suppressed because one or more lines are too long
1
dist/http-server-single-session.d.ts
vendored
1
dist/http-server-single-session.d.ts
vendored
@@ -21,6 +21,7 @@ export declare class SingleSessionHTTPServer {
|
||||
private getActiveSessionCount;
|
||||
private canCreateSession;
|
||||
private isValidSessionId;
|
||||
private isJsonRpcNotification;
|
||||
private sanitizeErrorForClient;
|
||||
private updateSessionAccess;
|
||||
private switchSessionContext;
|
||||
|
||||
2
dist/http-server-single-session.d.ts.map
vendored
2
dist/http-server-single-session.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"http-server-single-session.d.ts","sourceRoot":"","sources":["../src/http-server-single-session.ts"],"names":[],"mappings":";AAMA,OAAO,OAAO,MAAM,SAAS,CAAC;AAoB9B,OAAO,EAAE,eAAe,EAA2B,MAAM,0BAA0B,CAAC;AACpF,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAwErD,qBAAa,uBAAuB;IAElC,OAAO,CAAC,UAAU,CAA8D;IAChF,OAAO,CAAC,OAAO,CAA0D;IACzE,OAAO,CAAC,eAAe,CAAsE;IAC7F,OAAO,CAAC,eAAe,CAA4D;IACnF,OAAO,CAAC,kBAAkB,CAAyC;IACnE,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,cAAc,CAAwB;IAC9C,OAAO,CAAC,aAAa,CAAM;IAI3B,OAAO,CAAC,cAAc,CAER;IACd,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,YAAY,CAA+B;;IAcnD,OAAO,CAAC,mBAAmB;IAmB3B,OAAO,CAAC,sBAAsB;YAqChB,aAAa;IAuC3B,OAAO,CAAC,qBAAqB;IAO7B,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,gBAAgB;IASxB,OAAO,CAAC,sBAAsB;IAkC9B,OAAO,CAAC,mBAAmB;YASb,oBAAoB;YAwBpB,oBAAoB;IAwBlC,OAAO,CAAC,iBAAiB;IAsBzB,OAAO,CAAC,aAAa;IA2BrB,OAAO,CAAC,mBAAmB;IAoDrB,aAAa,CACjB,GAAG,EAAE,OAAO,CAAC,OAAO,EACpB,GAAG,EAAE,OAAO,CAAC,QAAQ,EACrB,eAAe,CAAC,EAAE,eAAe,GAChC,OAAO,CAAC,IAAI,CAAC;YA0PF,eAAe;IA4D7B,OAAO,CAAC,SAAS;IAYjB,OAAO,CAAC,gBAAgB;IASlB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAgnBtB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IA2D/B,cAAc,IAAI;QAChB,MAAM,EAAE,OAAO,CAAC;QAChB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,QAAQ,CAAC,EAAE;YACT,KAAK,EAAE,MAAM,CAAC;YACd,MAAM,EAAE,MAAM,CAAC;YACf,OAAO,EAAE,MAAM,CAAC;YAChB,GAAG,EAAE,MAAM,CAAC;YACZ,UAAU,EAAE,MAAM,EAAE,CAAC;SACtB,CAAC;KACH;IAmDM,kBAAkB,IAAI,YAAY,EAAE;IAoEpC,mBAAmB,CAAC,QAAQ,EAAE,YAAY,EAAE,GAAG,MAAM;CAsG7D"}
|
||||
{"version":3,"file":"http-server-single-session.d.ts","sourceRoot":"","sources":["../src/http-server-single-session.ts"],"names":[],"mappings":";AAMA,OAAO,OAAO,MAAM,SAAS,CAAC;AAoB9B,OAAO,EAAE,eAAe,EAA2B,MAAM,0BAA0B,CAAC;AACpF,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAwErD,qBAAa,uBAAuB;IAElC,OAAO,CAAC,UAAU,CAA8D;IAChF,OAAO,CAAC,OAAO,CAA0D;IACzE,OAAO,CAAC,eAAe,CAAsE;IAC7F,OAAO,CAAC,eAAe,CAA4D;IACnF,OAAO,CAAC,kBAAkB,CAAyC;IACnE,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,cAAc,CAAwB;IAC9C,OAAO,CAAC,aAAa,CAAM;IAI3B,OAAO,CAAC,cAAc,CAER;IACd,OAAO,CAAC,SAAS,CAAuB;IACxC,OAAO,CAAC,YAAY,CAA+B;;IAcnD,OAAO,CAAC,mBAAmB;IAmB3B,OAAO,CAAC,sBAAsB;YAqChB,aAAa;IAuC3B,OAAO,CAAC,qBAAqB;IAO7B,OAAO,CAAC,gBAAgB;IAkBxB,OAAO,CAAC,gBAAgB;IAYxB,OAAO,CAAC,qBAAqB;IAa7B,OAAO,CAAC,sBAAsB;IAkC9B,OAAO,CAAC,mBAAmB;YASb,oBAAoB;YAwBpB,oBAAoB;IAwBlC,OAAO,CAAC,iBAAiB;IAsBzB,OAAO,CAAC,aAAa;IA2BrB,OAAO,CAAC,mBAAmB;IAoDrB,aAAa,CACjB,GAAG,EAAE,OAAO,CAAC,OAAO,EACpB,GAAG,EAAE,OAAO,CAAC,QAAQ,EACrB,eAAe,CAAC,EAAE,eAAe,GAChC,OAAO,CAAC,IAAI,CAAC;YAoRF,eAAe;IA4D7B,OAAO,CAAC,SAAS;IAYjB,OAAO,CAAC,gBAAgB;IASlB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAgnBtB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IA2D/B,cAAc,IAAI;QAChB,MAAM,EAAE,OAAO,CAAC;QAChB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,QAAQ,CAAC,EAAE;YACT,KAAK,EAAE,MAAM,CAAC;YACd,MAAM,EAAE,MAAM,CAAC;YACf,OAAO,EAAE,MAAM,CAAC;YAChB,GAAG,EAAE,MAAM,CAAC;YACZ,UAAU,EAAE,MAAM,EAAE,CAAC;SACtB,CAAC;KACH;IAmDM,kBAAkB,IAAI,YAAY,EAAE;IAoEpC,mBAAmB,CAAC,QAAQ,EAAE,YAAY,EAAE,GAAG,MAAM;CAsG7D"}
|
||||
31
dist/http-server-single-session.js
vendored
31
dist/http-server-single-session.js
vendored
@@ -133,6 +133,15 @@ class SingleSessionHTTPServer {
|
||||
isValidSessionId(sessionId) {
|
||||
return Boolean(sessionId && sessionId.length > 0);
|
||||
}
|
||||
isJsonRpcNotification(body) {
|
||||
if (!body || typeof body !== 'object')
|
||||
return false;
|
||||
const isSingleNotification = (msg) => msg && typeof msg.method === 'string' && !('id' in msg);
|
||||
if (Array.isArray(body)) {
|
||||
return body.length > 0 && body.every(isSingleNotification);
|
||||
}
|
||||
return isSingleNotification(body);
|
||||
}
|
||||
sanitizeErrorForClient(error) {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
if (error instanceof Error) {
|
||||
@@ -381,6 +390,20 @@ class SingleSessionHTTPServer {
|
||||
}
|
||||
logger_1.logger.info('handleRequest: Reusing existing transport for session', { sessionId });
|
||||
transport = this.transports[sessionId];
|
||||
if (!transport) {
|
||||
if (this.isJsonRpcNotification(req.body)) {
|
||||
logger_1.logger.info('handleRequest: Session removed during lookup, accepting notification', { sessionId });
|
||||
res.status(202).end();
|
||||
return;
|
||||
}
|
||||
logger_1.logger.warn('handleRequest: Session removed between check and use (TOCTOU)', { sessionId });
|
||||
res.status(400).json({
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32000, message: 'Bad Request: Session not found or expired' },
|
||||
id: req.body?.id || null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const isMultiTenantEnabled = process.env.ENABLE_MULTI_TENANT === 'true';
|
||||
const sessionStrategy = process.env.MULTI_TENANT_SESSION_STRATEGY || 'instance';
|
||||
if (isMultiTenantEnabled && sessionStrategy === 'shared' && instanceContext) {
|
||||
@@ -389,6 +412,14 @@ class SingleSessionHTTPServer {
|
||||
this.updateSessionAccess(sessionId);
|
||||
}
|
||||
else {
|
||||
if (this.isJsonRpcNotification(req.body)) {
|
||||
logger_1.logger.info('handleRequest: Accepting notification for stale/missing session', {
|
||||
method: req.body?.method,
|
||||
sessionId: sessionId || 'none',
|
||||
});
|
||||
res.status(202).end();
|
||||
return;
|
||||
}
|
||||
const errorDetails = {
|
||||
hasSessionId: !!sessionId,
|
||||
isInitialize: isInitialize,
|
||||
|
||||
2
dist/http-server-single-session.js.map
vendored
2
dist/http-server-single-session.js.map
vendored
File diff suppressed because one or more lines are too long
2
dist/mcp/handlers-n8n-manager.d.ts.map
vendored
2
dist/mcp/handlers-n8n-manager.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"handlers-n8n-manager.d.ts","sourceRoot":"","sources":["../../src/mcp/handlers-n8n-manager.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"handlers-n8n-manager.d.ts","sourceRoot":"","sources":["../../src/mcp/handlers-n8n-manager.ts"],"names":[],"mappings":"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"}
|
||||
5
dist/mcp/handlers-n8n-manager.js
vendored
5
dist/mcp/handlers-n8n-manager.js
vendored
@@ -2176,10 +2176,13 @@ async function handleUpdateTable(args, context) {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { tableId, name } = updateTableSchema.parse(args);
|
||||
const dataTable = await client.updateDataTable(tableId, { name });
|
||||
const rawArgs = args;
|
||||
const hasColumns = rawArgs && typeof rawArgs === 'object' && 'columns' in rawArgs;
|
||||
return {
|
||||
success: true,
|
||||
data: dataTable,
|
||||
message: `Data table renamed to "${dataTable.name}"`,
|
||||
message: `Data table renamed to "${dataTable.name}"` +
|
||||
(hasColumns ? '. Note: columns parameter was ignored — table schema is immutable after creation via the public API' : ''),
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
2
dist/mcp/handlers-n8n-manager.js.map
vendored
2
dist/mcp/handlers-n8n-manager.js.map
vendored
File diff suppressed because one or more lines are too long
4
dist/mcp/handlers-workflow-diff.js
vendored
4
dist/mcp/handlers-workflow-diff.js
vendored
@@ -231,9 +231,9 @@ async function handleUpdatePartialWorkflow(args, repository, context) {
|
||||
});
|
||||
const recoverySteps = [];
|
||||
if (errorTypes.has('operator_issues')) {
|
||||
recoverySteps.push('Operator structure issue detected. Use validate_node_operation to check specific nodes.');
|
||||
recoverySteps.push('Operator structure issue detected. Use validate_node to check specific nodes.');
|
||||
recoverySteps.push('Binary operators (equals, contains, greaterThan, etc.) must NOT have singleValue:true');
|
||||
recoverySteps.push('Unary operators (isEmpty, isNotEmpty, true, false) REQUIRE singleValue:true');
|
||||
recoverySteps.push('Unary operators (empty, notEmpty, true, false) REQUIRE singleValue:true');
|
||||
}
|
||||
if (errorTypes.has('connection_issues')) {
|
||||
recoverySteps.push('Connection validation failed. Check all node connections reference existing nodes.');
|
||||
|
||||
2
dist/mcp/handlers-workflow-diff.js.map
vendored
2
dist/mcp/handlers-workflow-diff.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"version":3,"file":"n8n-update-partial-workflow.d.ts","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAE7C,eAAO,MAAM,2BAA2B,EAAE,iBAuazC,CAAC"}
|
||||
{"version":3,"file":"n8n-update-partial-workflow.d.ts","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAE7C,eAAO,MAAM,2BAA2B,EAAE,iBA2azC,CAAC"}
|
||||
@@ -120,8 +120,8 @@ When ANY workflow update is made, ALL nodes in the workflow are automatically sa
|
||||
|
||||
1. **Operator Structure Fixes**:
|
||||
- Binary operators (equals, contains, greaterThan, etc.) automatically have \`singleValue\` removed
|
||||
- Unary operators (isEmpty, isNotEmpty, true, false) automatically get \`singleValue: true\` added
|
||||
- Invalid operator structures (e.g., \`{type: "isNotEmpty"}\`) are corrected to \`{type: "boolean", operation: "isNotEmpty"}\`
|
||||
- Unary operators (empty, notEmpty, true, false) automatically get \`singleValue: true\` added
|
||||
- Invalid operator structures (e.g., \`{type: "notEmpty"}\`) are corrected to \`{type: "object", operation: "notEmpty"}\`
|
||||
|
||||
2. **Missing Metadata Added**:
|
||||
- IF nodes with conditions get complete \`conditions.options\` structure if missing
|
||||
@@ -334,6 +334,8 @@ n8n_update_partial_workflow({
|
||||
'// Best-effort mode: apply what works, report what fails\nn8n_update_partial_workflow({id: "vwx", operations: [\n {type: "updateName", name: "Fixed Workflow"},\n {type: "removeConnection", source: "Broken", target: "Node"},\n {type: "cleanStaleConnections"}\n], continueOnError: true})',
|
||||
'// Update node parameter\nn8n_update_partial_workflow({id: "yza", operations: [{type: "updateNode", nodeName: "HTTP Request", updates: {"parameters.url": "https://api.example.com"}}]})',
|
||||
'// Validate before applying\nn8n_update_partial_workflow({id: "bcd", operations: [{type: "removeNode", nodeName: "Old Process"}], validateOnly: true})',
|
||||
'// Surgically edit code using __patch_find_replace (avoids replacing entire code block)\nn8n_update_partial_workflow({id: "pfr1", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "const limit = 10;", "replace": "const limit = 50;"}]}}}]})',
|
||||
'// Multiple sequential patches on the same property\nn8n_update_partial_workflow({id: "pfr2", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "api.old-domain.com", "replace": "api.new-domain.com"}, {"find": "Authorization: Bearer old_token", "replace": "Authorization: Bearer new_token"}]}}}]})',
|
||||
'\n// ============ AI CONNECTION EXAMPLES ============',
|
||||
'// Connect language model to AI Agent\nn8n_update_partial_workflow({id: "ai1", operations: [{type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel"}]})',
|
||||
'// Connect tool to AI Agent\nn8n_update_partial_workflow({id: "ai2", operations: [{type: "addConnection", source: "HTTP Request Tool", target: "AI Agent", sourceOutput: "ai_tool"}]})',
|
||||
@@ -412,10 +414,12 @@ n8n_update_partial_workflow({
|
||||
'**CRITICAL**: For Switch nodes, ALWAYS use case=N instead of sourceIndex. Using same sourceIndex for multiple connections will put them on the same case output.',
|
||||
'cleanStaleConnections removes ALL broken connections - cannot be selective',
|
||||
'replaceConnections overwrites entire connections object - all previous connections lost',
|
||||
'**Auto-sanitization behavior**: Binary operators (equals, contains) automatically have singleValue removed; unary operators (isEmpty, isNotEmpty) automatically get singleValue:true added',
|
||||
'**Auto-sanitization behavior**: Binary operators (equals, contains) automatically have singleValue removed; unary operators (empty, notEmpty) automatically get singleValue:true added',
|
||||
'**Auto-sanitization runs on ALL nodes**: When ANY update is made, ALL nodes in the workflow are sanitized (not just modified ones)',
|
||||
'**Auto-sanitization cannot fix everything**: It fixes operator structures and missing metadata, but cannot fix broken connections or branch mismatches',
|
||||
'**Corrupted workflows beyond repair**: Workflows in paradoxical states (API returns corrupt, API rejects updates) cannot be fixed via API - must be recreated',
|
||||
'**__patch_find_replace for code edits**: Instead of replacing entire code blocks, use `{"parameters.jsCode": {"__patch_find_replace": [{"find": "old text", "replace": "new text"}]}}` to surgically edit string properties',
|
||||
'__patch_find_replace replaces the FIRST occurrence of each find string. Patches are applied sequentially — order matters',
|
||||
'To remove a property, set it to null in the updates object',
|
||||
'When properties are mutually exclusive (e.g., continueOnFail and onError), setting only the new property will fail - you must remove the old one with null',
|
||||
'Removing a required property may cause validation errors - check node documentation first',
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"n8n-update-partial-workflow.js","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"n8n-update-partial-workflow.js","sourceRoot":"","sources":["../../../../src/mcp/tool-docs/workflow_management/n8n-update-partial-workflow.ts"],"names":[],"mappings":"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"}
|
||||
6
dist/mcp/tools-n8n-manager.js
vendored
6
dist/mcp/tools-n8n-manager.js
vendored
@@ -590,7 +590,7 @@ exports.n8nManagementTools = [
|
||||
},
|
||||
{
|
||||
name: 'n8n_manage_datatable',
|
||||
description: `Manage n8n data tables and rows. Actions: createTable, listTables, getTable, updateTable, deleteTable, getRows, insertRows, updateRows, upsertRows, deleteRows. Requires n8n enterprise/cloud with data tables feature.`,
|
||||
description: `Manage n8n data tables and rows. Actions: createTable, listTables, getTable, updateTable, deleteTable, getRows, insertRows, updateRows, upsertRows, deleteRows.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -600,10 +600,10 @@ exports.n8nManagementTools = [
|
||||
description: 'Operation to perform',
|
||||
},
|
||||
tableId: { type: 'string', description: 'Data table ID (required for all actions except createTable and listTables)' },
|
||||
name: { type: 'string', description: 'For createTable/updateTable: table name' },
|
||||
name: { type: 'string', description: 'For createTable: table name. For updateTable: new name (rename only — schema is immutable after creation)' },
|
||||
columns: {
|
||||
type: 'array',
|
||||
description: 'For createTable: column definitions',
|
||||
description: 'For createTable only: column definitions (schema is immutable after creation via public API)',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
||||
2
dist/mcp/tools-n8n-manager.js.map
vendored
2
dist/mcp/tools-n8n-manager.js.map
vendored
File diff suppressed because one or more lines are too long
3
dist/scripts/rebuild.js
vendored
3
dist/scripts/rebuild.js
vendored
@@ -130,6 +130,9 @@ async function rebuild() {
|
||||
}
|
||||
}
|
||||
console.log(`💾 Save completed: ${saved} nodes saved successfully`);
|
||||
console.log('\n🔍 Rebuilding FTS5 search index...');
|
||||
db.prepare("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')").run();
|
||||
console.log('✅ FTS5 index rebuilt successfully');
|
||||
console.log('\n🔍 Running validation checks...');
|
||||
try {
|
||||
const validationResults = validateDatabase(repository);
|
||||
|
||||
2
dist/scripts/rebuild.js.map
vendored
2
dist/scripts/rebuild.js.map
vendored
File diff suppressed because one or more lines are too long
14
dist/services/enhanced-config-validator.js
vendored
14
dist/services/enhanced-config-validator.js
vendored
@@ -730,30 +730,30 @@ class EnhancedConfigValidator extends config_validator_1.ConfigValidator {
|
||||
'empty', 'notEmpty', 'equals', 'notEquals',
|
||||
'contains', 'notContains', 'startsWith', 'notStartsWith',
|
||||
'endsWith', 'notEndsWith', 'regex', 'notRegex',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
'exists', 'notExists'
|
||||
],
|
||||
number: [
|
||||
'empty', 'notEmpty', 'equals', 'notEquals', 'gt', 'lt', 'gte', 'lte',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
'exists', 'notExists'
|
||||
],
|
||||
dateTime: [
|
||||
'empty', 'notEmpty', 'equals', 'notEquals', 'after', 'before', 'afterOrEquals', 'beforeOrEquals',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
'exists', 'notExists'
|
||||
],
|
||||
boolean: [
|
||||
'empty', 'notEmpty', 'true', 'false', 'equals', 'notEquals',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
'exists', 'notExists'
|
||||
],
|
||||
array: [
|
||||
'contains', 'notContains', 'lengthEquals', 'lengthNotEquals',
|
||||
'lengthGt', 'lengthLt', 'lengthGte', 'lengthLte', 'empty', 'notEmpty',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
'exists', 'notExists'
|
||||
],
|
||||
object: [
|
||||
'empty', 'notEmpty',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
'exists', 'notExists'
|
||||
],
|
||||
any: ['exists', 'notExists', 'isNotEmpty']
|
||||
any: ['exists', 'notExists']
|
||||
};
|
||||
for (let i = 0; i < conditions.length; i++) {
|
||||
const condition = conditions[i];
|
||||
|
||||
File diff suppressed because one or more lines are too long
6
dist/services/n8n-validation.js
vendored
6
dist/services/n8n-validation.js
vendored
@@ -419,10 +419,10 @@ function validateOperatorStructure(operator, path) {
|
||||
}
|
||||
if (!operator.operation) {
|
||||
errors.push(`${path}: missing required field "operation". ` +
|
||||
'Operation specifies the comparison type (e.g., "equals", "contains", "isNotEmpty")');
|
||||
'Operation specifies the comparison type (e.g., "equals", "contains", "notEmpty")');
|
||||
}
|
||||
if (operator.operation) {
|
||||
const unaryOperators = ['isEmpty', 'isNotEmpty', 'true', 'false', 'isNumeric'];
|
||||
const unaryOperators = ['empty', 'notEmpty', 'true', 'false', 'isNumeric', 'exists', 'notExists'];
|
||||
const isUnary = unaryOperators.includes(operator.operation);
|
||||
if (isUnary) {
|
||||
if (operator.singleValue !== true) {
|
||||
@@ -433,7 +433,7 @@ function validateOperatorStructure(operator, path) {
|
||||
else {
|
||||
if (operator.singleValue === true) {
|
||||
errors.push(`${path}: binary operator "${operator.operation}" should not have "singleValue: true". ` +
|
||||
'Only unary operators (isEmpty, isNotEmpty, true, false, isNumeric) need this property.');
|
||||
'Only unary operators (empty, notEmpty, true, false, isNumeric, exists, notExists) need this property.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
dist/services/n8n-validation.js.map
vendored
2
dist/services/n8n-validation.js.map
vendored
File diff suppressed because one or more lines are too long
2
dist/services/node-sanitizer.d.ts.map
vendored
2
dist/services/node-sanitizer.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"node-sanitizer.d.ts","sourceRoot":"","sources":["../../src/services/node-sanitizer.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAKhD,wBAAgB,YAAY,CAAC,IAAI,EAAE,YAAY,GAAG,YAAY,CAa7D;AAKD,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,GAAG,GAAG,GAAG,CASxD;AA6ND,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,YAAY,GAAG,MAAM,EAAE,CAgEjE"}
|
||||
{"version":3,"file":"node-sanitizer.d.ts","sourceRoot":"","sources":["../../src/services/node-sanitizer.ts"],"names":[],"mappings":"AAaA,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAsBhD,wBAAgB,YAAY,CAAC,IAAI,EAAE,YAAY,GAAG,YAAY,CAa7D;AAKD,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,GAAG,GAAG,GAAG,CASxD;AAgND,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,YAAY,GAAG,MAAM,EAAE,CAgEjE"}
|
||||
34
dist/services/node-sanitizer.js
vendored
34
dist/services/node-sanitizer.js
vendored
@@ -4,6 +4,19 @@ exports.sanitizeNode = sanitizeNode;
|
||||
exports.sanitizeWorkflowNodes = sanitizeWorkflowNodes;
|
||||
exports.validateNodeMetadata = validateNodeMetadata;
|
||||
const logger_1 = require("../utils/logger");
|
||||
const OPERATOR_CORRECTIONS = {
|
||||
'isEmpty': 'empty',
|
||||
'isNotEmpty': 'notEmpty',
|
||||
};
|
||||
const UNARY_OPERATORS = new Set([
|
||||
'true',
|
||||
'false',
|
||||
'isNumeric',
|
||||
'empty',
|
||||
'notEmpty',
|
||||
'exists',
|
||||
'notExists',
|
||||
]);
|
||||
function sanitizeNode(node) {
|
||||
const sanitized = { ...node };
|
||||
if (isFilterBasedNode(node.type, node.typeVersion)) {
|
||||
@@ -92,11 +105,13 @@ function sanitizeOperator(operator) {
|
||||
const typeValue = sanitized.type;
|
||||
if (isOperationName(typeValue)) {
|
||||
logger_1.logger.debug(`Fixing operator structure: converting type="${typeValue}" to operation`);
|
||||
const dataType = inferDataType(typeValue);
|
||||
sanitized.type = dataType;
|
||||
sanitized.type = inferDataType(typeValue);
|
||||
sanitized.operation = typeValue;
|
||||
}
|
||||
}
|
||||
if (sanitized.operation && OPERATOR_CORRECTIONS[sanitized.operation]) {
|
||||
sanitized.operation = OPERATOR_CORRECTIONS[sanitized.operation];
|
||||
}
|
||||
if (sanitized.operation) {
|
||||
if (isUnaryOperator(sanitized.operation)) {
|
||||
sanitized.singleValue = true;
|
||||
@@ -112,7 +127,7 @@ function isOperationName(value) {
|
||||
return !dataTypes.includes(value) && /^[a-z][a-zA-Z]*$/.test(value);
|
||||
}
|
||||
function inferDataType(operation) {
|
||||
const booleanOps = ['true', 'false', 'isEmpty', 'isNotEmpty'];
|
||||
const booleanOps = ['true', 'false'];
|
||||
if (booleanOps.includes(operation)) {
|
||||
return 'boolean';
|
||||
}
|
||||
@@ -131,18 +146,7 @@ function inferDataType(operation) {
|
||||
return 'string';
|
||||
}
|
||||
function isUnaryOperator(operation) {
|
||||
const unaryOps = [
|
||||
'isEmpty',
|
||||
'isNotEmpty',
|
||||
'true',
|
||||
'false',
|
||||
'isNumeric',
|
||||
'empty',
|
||||
'notEmpty',
|
||||
'exists',
|
||||
'notExists'
|
||||
];
|
||||
return unaryOps.includes(operation);
|
||||
return UNARY_OPERATORS.has(operation);
|
||||
}
|
||||
function generateConditionId() {
|
||||
return `condition-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
2
dist/services/node-sanitizer.js.map
vendored
2
dist/services/node-sanitizer.js.map
vendored
File diff suppressed because one or more lines are too long
1
dist/services/workflow-diff-engine.d.ts
vendored
1
dist/services/workflow-diff-engine.d.ts
vendored
@@ -47,6 +47,7 @@ export declare class WorkflowDiffEngine {
|
||||
private normalizeNodeName;
|
||||
private findNode;
|
||||
private formatNodeNotFoundError;
|
||||
private getNestedProperty;
|
||||
private setNestedProperty;
|
||||
}
|
||||
//# sourceMappingURL=workflow-diff-engine.d.ts.map
|
||||
2
dist/services/workflow-diff-engine.d.ts.map
vendored
2
dist/services/workflow-diff-engine.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"workflow-diff-engine.d.ts","sourceRoot":"","sources":["../../src/services/workflow-diff-engine.ts"],"names":[],"mappings":"AAMA,OAAO,EAEL,mBAAmB,EACnB,kBAAkB,EAuBnB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,QAAQ,EAAoC,MAAM,kBAAkB,CAAC;AAY9E,qBAAa,kBAAkB;IAE7B,OAAO,CAAC,SAAS,CAAkC;IAEnD,OAAO,CAAC,QAAQ,CAAqC;IAErD,OAAO,CAAC,eAAe,CAAqB;IAE5C,OAAO,CAAC,gBAAgB,CAAqB;IAE7C,OAAO,CAAC,SAAS,CAAgB;IACjC,OAAO,CAAC,YAAY,CAAgB;IAEpC,OAAO,CAAC,mBAAmB,CAAqB;IAK1C,SAAS,CACb,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,kBAAkB,CAAC;IAgO9B,OAAO,CAAC,iBAAiB;IA0CzB,OAAO,CAAC,cAAc;IA4DtB,OAAO,CAAC,eAAe;IAwBvB,OAAO,CAAC,kBAAkB;IAuB1B,OAAO,CAAC,kBAAkB;IAoC1B,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CAAC,kBAAkB;IAU1B,OAAO,CAAC,qBAAqB;IAkD7B,OAAO,CAAC,wBAAwB;IA6ChC,OAAO,CAAC,wBAAwB;IAmDhC,OAAO,CAAC,YAAY;IA4BpB,OAAO,CAAC,eAAe;IAwCvB,OAAO,CAAC,eAAe;IA0BvB,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,eAAe;IAOvB,OAAO,CAAC,gBAAgB;IAWxB,OAAO,CAAC,sBAAsB;IAwD9B,OAAO,CAAC,kBAAkB;IA6C1B,OAAO,CAAC,qBAAqB;IAuC7B,OAAO,CAAC,qBAAqB;IA0B7B,OAAO,CAAC,mBAAmB;IAW3B,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,WAAW;IAYnB,OAAO,CAAC,cAAc;IAatB,OAAO,CAAC,wBAAwB;IAchC,OAAO,CAAC,0BAA0B;IAMlC,OAAO,CAAC,qBAAqB;IAM7B,OAAO,CAAC,uBAAuB;IAO/B,OAAO,CAAC,wBAAwB;IAOhC,OAAO,CAAC,qBAAqB;IAK7B,OAAO,CAAC,6BAA6B;IAKrC,OAAO,CAAC,0BAA0B;IA0BlC,OAAO,CAAC,0BAA0B;IA+ElC,OAAO,CAAC,uBAAuB;IAe/B,OAAO,CAAC,0BAA0B;IAmElC,OAAO,CAAC,iBAAiB;IAkBzB,OAAO,CAAC,QAAQ;IAsChB,OAAO,CAAC,uBAAuB;IAW/B,OAAO,CAAC,iBAAiB;CAoB1B"}
|
||||
{"version":3,"file":"workflow-diff-engine.d.ts","sourceRoot":"","sources":["../../src/services/workflow-diff-engine.ts"],"names":[],"mappings":"AAMA,OAAO,EAEL,mBAAmB,EACnB,kBAAkB,EAuBnB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,QAAQ,EAAoC,MAAM,kBAAkB,CAAC;AAY9E,qBAAa,kBAAkB;IAE7B,OAAO,CAAC,SAAS,CAAkC;IAEnD,OAAO,CAAC,QAAQ,CAAqC;IAErD,OAAO,CAAC,eAAe,CAAqB;IAE5C,OAAO,CAAC,gBAAgB,CAAqB;IAE7C,OAAO,CAAC,SAAS,CAAgB;IACjC,OAAO,CAAC,YAAY,CAAgB;IAEpC,OAAO,CAAC,mBAAmB,CAAqB;IAK1C,SAAS,CACb,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,kBAAkB,CAAC;IAgO9B,OAAO,CAAC,iBAAiB;IA0CzB,OAAO,CAAC,cAAc;IA4DtB,OAAO,CAAC,eAAe;IAwBvB,OAAO,CAAC,kBAAkB;IAuB1B,OAAO,CAAC,kBAAkB;IA6D1B,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CAAC,kBAAkB;IAU1B,OAAO,CAAC,qBAAqB;IAkD7B,OAAO,CAAC,wBAAwB;IA6ChC,OAAO,CAAC,wBAAwB;IAmDhC,OAAO,CAAC,YAAY;IA4BpB,OAAO,CAAC,eAAe;IAwCvB,OAAO,CAAC,eAAe;IA6CvB,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,eAAe;IAOvB,OAAO,CAAC,gBAAgB;IAWxB,OAAO,CAAC,sBAAsB;IA0D9B,OAAO,CAAC,kBAAkB;IAiD1B,OAAO,CAAC,qBAAqB;IAuC7B,OAAO,CAAC,qBAAqB;IA0B7B,OAAO,CAAC,mBAAmB;IAW3B,OAAO,CAAC,eAAe;IAIvB,OAAO,CAAC,WAAW;IAYnB,OAAO,CAAC,cAAc;IAatB,OAAO,CAAC,wBAAwB;IAchC,OAAO,CAAC,0BAA0B;IAMlC,OAAO,CAAC,qBAAqB;IAM7B,OAAO,CAAC,uBAAuB;IAO/B,OAAO,CAAC,wBAAwB;IAOhC,OAAO,CAAC,qBAAqB;IAK7B,OAAO,CAAC,6BAA6B;IAKrC,OAAO,CAAC,0BAA0B;IA0BlC,OAAO,CAAC,0BAA0B;IA+ElC,OAAO,CAAC,uBAAuB;IAe/B,OAAO,CAAC,0BAA0B;IAmElC,OAAO,CAAC,iBAAiB;IAkBzB,OAAO,CAAC,QAAQ;IAsChB,OAAO,CAAC,uBAAuB;IAW/B,OAAO,CAAC,iBAAiB;IAUzB,OAAO,CAAC,iBAAiB;CAoB1B"}
|
||||
63
dist/services/workflow-diff-engine.js
vendored
63
dist/services/workflow-diff-engine.js
vendored
@@ -351,6 +351,28 @@ class WorkflowDiffEngine {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [path, value] of Object.entries(operation.updates)) {
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
&& '__patch_find_replace' in value) {
|
||||
const patches = value.__patch_find_replace;
|
||||
if (!Array.isArray(patches)) {
|
||||
return `Invalid __patch_find_replace at "${path}": must be an array of {find, replace} objects`;
|
||||
}
|
||||
for (let i = 0; i < patches.length; i++) {
|
||||
const patch = patches[i];
|
||||
if (!patch || typeof patch.find !== 'string' || typeof patch.replace !== 'string') {
|
||||
return `Invalid __patch_find_replace entry at "${path}[${i}]": each entry must have "find" (string) and "replace" (string)`;
|
||||
}
|
||||
}
|
||||
const currentValue = this.getNestedProperty(node, path);
|
||||
if (currentValue === undefined) {
|
||||
return `Cannot apply __patch_find_replace to "${path}": property does not exist on node`;
|
||||
}
|
||||
if (typeof currentValue !== 'string') {
|
||||
return `Cannot apply __patch_find_replace to "${path}": current value is ${typeof currentValue}, expected string`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
validateMoveNode(workflow, operation) {
|
||||
@@ -541,7 +563,25 @@ class WorkflowDiffEngine {
|
||||
logger.debug(`Tracking rename: "${oldName}" → "${newName}"`);
|
||||
}
|
||||
Object.entries(operation.updates).forEach(([path, value]) => {
|
||||
this.setNestedProperty(node, path, value);
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
&& '__patch_find_replace' in value) {
|
||||
const patches = value.__patch_find_replace;
|
||||
let current = this.getNestedProperty(node, path);
|
||||
for (const patch of patches) {
|
||||
if (!current.includes(patch.find)) {
|
||||
this.warnings.push({
|
||||
operation: -1,
|
||||
message: `__patch_find_replace: "${patch.find.substring(0, 50)}" not found in "${path}". Skipped.`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
current = current.replace(patch.find, patch.replace);
|
||||
}
|
||||
this.setNestedProperty(node, path, current);
|
||||
}
|
||||
else {
|
||||
this.setNestedProperty(node, path, value);
|
||||
}
|
||||
});
|
||||
const sanitized = (0, node_sanitizer_1.sanitizeNode)(node);
|
||||
Object.assign(node, sanitized);
|
||||
@@ -568,9 +608,11 @@ class WorkflowDiffEngine {
|
||||
const sourceNode = this.findNode(workflow, operation.source, operation.source);
|
||||
let sourceOutput = String(operation.sourceOutput ?? 'main');
|
||||
let sourceIndex = operation.sourceIndex ?? 0;
|
||||
if (/^\d+$/.test(sourceOutput) && operation.sourceIndex === undefined
|
||||
const numericOutput = /^\d+$/.test(sourceOutput) ? parseInt(sourceOutput, 10) : null;
|
||||
if (numericOutput !== null
|
||||
&& (operation.sourceIndex === undefined || operation.sourceIndex === numericOutput)
|
||||
&& operation.branch === undefined && operation.case === undefined) {
|
||||
sourceIndex = parseInt(sourceOutput, 10);
|
||||
sourceIndex = numericOutput;
|
||||
sourceOutput = 'main';
|
||||
}
|
||||
if (operation.branch !== undefined && operation.sourceIndex === undefined) {
|
||||
@@ -606,7 +648,10 @@ class WorkflowDiffEngine {
|
||||
if (!sourceNode || !targetNode)
|
||||
return;
|
||||
const { sourceOutput, sourceIndex } = this.resolveSmartParameters(workflow, operation);
|
||||
const targetInput = String(operation.targetInput ?? sourceOutput);
|
||||
let targetInput = String(operation.targetInput ?? sourceOutput);
|
||||
if (/^\d+$/.test(targetInput)) {
|
||||
targetInput = 'main';
|
||||
}
|
||||
const targetIndex = operation.targetIndex ?? 0;
|
||||
if (!workflow.connections[sourceNode.name]) {
|
||||
workflow.connections[sourceNode.name] = {};
|
||||
@@ -875,6 +920,16 @@ class WorkflowDiffEngine {
|
||||
.join(', ');
|
||||
return `Node not found for ${operationType}: "${nodeIdentifier}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters (apostrophes, quotes).`;
|
||||
}
|
||||
getNestedProperty(obj, path) {
|
||||
const keys = path.split('.');
|
||||
let current = obj;
|
||||
for (const key of keys) {
|
||||
if (current == null || typeof current !== 'object')
|
||||
return undefined;
|
||||
current = current[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
setNestedProperty(obj, path, value) {
|
||||
const keys = path.split('.');
|
||||
let current = obj;
|
||||
|
||||
2
dist/services/workflow-diff-engine.js.map
vendored
2
dist/services/workflow-diff-engine.js.map
vendored
File diff suppressed because one or more lines are too long
9017
package-lock.json
generated
9017
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",
|
||||
"version": "2.40.5",
|
||||
"version": "2.41.3",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -92,10 +92,6 @@
|
||||
"test:docker:security": "./scripts/test-docker-config.sh security",
|
||||
"sanitize:templates": "node dist/scripts/sanitize-templates.js",
|
||||
"db:rebuild": "node dist/scripts/rebuild-database.js",
|
||||
"benchmark": "vitest bench --config vitest.config.benchmark.ts",
|
||||
"benchmark:watch": "vitest bench --watch --config vitest.config.benchmark.ts",
|
||||
"benchmark:ui": "vitest bench --ui --config vitest.config.benchmark.ts",
|
||||
"benchmark:ci": "CI=true node scripts/run-benchmarks-ci.js",
|
||||
"db:init": "node -e \"new (require('./dist/services/sqlite-storage-service').SQLiteStorageService)(); console.log('Database initialized')\"",
|
||||
"docs:rebuild": "ts-node src/scripts/rebuild-database.ts",
|
||||
"sync:runtime-version": "node scripts/sync-runtime-version.js",
|
||||
@@ -152,17 +148,17 @@
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@n8n/n8n-nodes-langchain": "^2.12.0",
|
||||
"@modelcontextprotocol/sdk": "1.28.0",
|
||||
"@n8n/n8n-nodes-langchain": "^2.13.1",
|
||||
"@supabase/supabase-js": "^2.57.4",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"form-data": "^4.0.5",
|
||||
"lru-cache": "^11.2.1",
|
||||
"n8n": "^2.12.3",
|
||||
"n8n-core": "^2.12.0",
|
||||
"n8n-workflow": "^2.12.0",
|
||||
"n8n": "^2.13.3",
|
||||
"n8n-core": "^2.13.1",
|
||||
"n8n-workflow": "^2.13.1",
|
||||
"openai": "^4.77.0",
|
||||
"sql.js": "^1.13.0",
|
||||
"tslib": "^2.6.2",
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync, existsSync, writeFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
/**
|
||||
* Compare benchmark results between runs
|
||||
*/
|
||||
class BenchmarkComparator {
|
||||
constructor() {
|
||||
this.threshold = 0.1; // 10% threshold for significant changes
|
||||
}
|
||||
|
||||
loadBenchmarkResults(path) {
|
||||
if (!existsSync(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8'));
|
||||
} catch (error) {
|
||||
console.error(`Error loading benchmark results from ${path}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
compareBenchmarks(current, baseline) {
|
||||
const comparison = {
|
||||
timestamp: new Date().toISOString(),
|
||||
summary: {
|
||||
improved: 0,
|
||||
regressed: 0,
|
||||
unchanged: 0,
|
||||
added: 0,
|
||||
removed: 0
|
||||
},
|
||||
benchmarks: []
|
||||
};
|
||||
|
||||
// Create maps for easy lookup
|
||||
const currentMap = new Map();
|
||||
const baselineMap = new Map();
|
||||
|
||||
// Process current benchmarks
|
||||
if (current && current.files) {
|
||||
for (const file of current.files) {
|
||||
for (const group of file.groups || []) {
|
||||
for (const bench of group.benchmarks || []) {
|
||||
const key = `${group.name}::${bench.name}`;
|
||||
currentMap.set(key, {
|
||||
ops: bench.result.hz,
|
||||
mean: bench.result.mean,
|
||||
file: file.filepath
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process baseline benchmarks
|
||||
if (baseline && baseline.files) {
|
||||
for (const file of baseline.files) {
|
||||
for (const group of file.groups || []) {
|
||||
for (const bench of group.benchmarks || []) {
|
||||
const key = `${group.name}::${bench.name}`;
|
||||
baselineMap.set(key, {
|
||||
ops: bench.result.hz,
|
||||
mean: bench.result.mean,
|
||||
file: file.filepath
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compare benchmarks
|
||||
for (const [key, current] of currentMap) {
|
||||
const baseline = baselineMap.get(key);
|
||||
|
||||
if (!baseline) {
|
||||
// New benchmark
|
||||
comparison.summary.added++;
|
||||
comparison.benchmarks.push({
|
||||
name: key,
|
||||
status: 'added',
|
||||
current: current.ops,
|
||||
baseline: null,
|
||||
change: null,
|
||||
file: current.file
|
||||
});
|
||||
} else {
|
||||
// Compare performance
|
||||
const change = ((current.ops - baseline.ops) / baseline.ops) * 100;
|
||||
let status = 'unchanged';
|
||||
|
||||
if (Math.abs(change) >= this.threshold * 100) {
|
||||
if (change > 0) {
|
||||
status = 'improved';
|
||||
comparison.summary.improved++;
|
||||
} else {
|
||||
status = 'regressed';
|
||||
comparison.summary.regressed++;
|
||||
}
|
||||
} else {
|
||||
comparison.summary.unchanged++;
|
||||
}
|
||||
|
||||
comparison.benchmarks.push({
|
||||
name: key,
|
||||
status,
|
||||
current: current.ops,
|
||||
baseline: baseline.ops,
|
||||
change,
|
||||
meanCurrent: current.mean,
|
||||
meanBaseline: baseline.mean,
|
||||
file: current.file
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for removed benchmarks
|
||||
for (const [key, baseline] of baselineMap) {
|
||||
if (!currentMap.has(key)) {
|
||||
comparison.summary.removed++;
|
||||
comparison.benchmarks.push({
|
||||
name: key,
|
||||
status: 'removed',
|
||||
current: null,
|
||||
baseline: baseline.ops,
|
||||
change: null,
|
||||
file: baseline.file
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by change percentage (regressions first)
|
||||
comparison.benchmarks.sort((a, b) => {
|
||||
if (a.status === 'regressed' && b.status !== 'regressed') return -1;
|
||||
if (b.status === 'regressed' && a.status !== 'regressed') return 1;
|
||||
if (a.change !== null && b.change !== null) {
|
||||
return a.change - b.change;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
return comparison;
|
||||
}
|
||||
|
||||
generateMarkdownReport(comparison) {
|
||||
let report = '## Benchmark Comparison Report\n\n';
|
||||
|
||||
const { summary } = comparison;
|
||||
report += '### Summary\n\n';
|
||||
report += `- **Improved**: ${summary.improved} benchmarks\n`;
|
||||
report += `- **Regressed**: ${summary.regressed} benchmarks\n`;
|
||||
report += `- **Unchanged**: ${summary.unchanged} benchmarks\n`;
|
||||
report += `- **Added**: ${summary.added} benchmarks\n`;
|
||||
report += `- **Removed**: ${summary.removed} benchmarks\n\n`;
|
||||
|
||||
// Regressions
|
||||
const regressions = comparison.benchmarks.filter(b => b.status === 'regressed');
|
||||
if (regressions.length > 0) {
|
||||
report += '### ⚠️ Performance Regressions\n\n';
|
||||
report += '| Benchmark | Current | Baseline | Change |\n';
|
||||
report += '|-----------|---------|----------|--------|\n';
|
||||
|
||||
for (const bench of regressions) {
|
||||
const currentOps = bench.current.toLocaleString('en-US', { maximumFractionDigits: 0 });
|
||||
const baselineOps = bench.baseline.toLocaleString('en-US', { maximumFractionDigits: 0 });
|
||||
const changeStr = bench.change.toFixed(2);
|
||||
report += `| ${bench.name} | ${currentOps} ops/s | ${baselineOps} ops/s | **${changeStr}%** |\n`;
|
||||
}
|
||||
report += '\n';
|
||||
}
|
||||
|
||||
// Improvements
|
||||
const improvements = comparison.benchmarks.filter(b => b.status === 'improved');
|
||||
if (improvements.length > 0) {
|
||||
report += '### ✅ Performance Improvements\n\n';
|
||||
report += '| Benchmark | Current | Baseline | Change |\n';
|
||||
report += '|-----------|---------|----------|--------|\n';
|
||||
|
||||
for (const bench of improvements) {
|
||||
const currentOps = bench.current.toLocaleString('en-US', { maximumFractionDigits: 0 });
|
||||
const baselineOps = bench.baseline.toLocaleString('en-US', { maximumFractionDigits: 0 });
|
||||
const changeStr = bench.change.toFixed(2);
|
||||
report += `| ${bench.name} | ${currentOps} ops/s | ${baselineOps} ops/s | **+${changeStr}%** |\n`;
|
||||
}
|
||||
report += '\n';
|
||||
}
|
||||
|
||||
// New benchmarks
|
||||
const added = comparison.benchmarks.filter(b => b.status === 'added');
|
||||
if (added.length > 0) {
|
||||
report += '### 🆕 New Benchmarks\n\n';
|
||||
report += '| Benchmark | Performance |\n';
|
||||
report += '|-----------|-------------|\n';
|
||||
|
||||
for (const bench of added) {
|
||||
const ops = bench.current.toLocaleString('en-US', { maximumFractionDigits: 0 });
|
||||
report += `| ${bench.name} | ${ops} ops/s |\n`;
|
||||
}
|
||||
report += '\n';
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
generateJsonReport(comparison) {
|
||||
return JSON.stringify(comparison, null, 2);
|
||||
}
|
||||
|
||||
async compare(currentPath, baselinePath) {
|
||||
// Load results
|
||||
const current = this.loadBenchmarkResults(currentPath);
|
||||
const baseline = this.loadBenchmarkResults(baselinePath);
|
||||
|
||||
if (!current && !baseline) {
|
||||
console.error('No benchmark results found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate comparison
|
||||
const comparison = this.compareBenchmarks(current, baseline);
|
||||
|
||||
// Generate reports
|
||||
const markdownReport = this.generateMarkdownReport(comparison);
|
||||
const jsonReport = this.generateJsonReport(comparison);
|
||||
|
||||
// Write reports
|
||||
writeFileSync('benchmark-comparison.md', markdownReport);
|
||||
writeFileSync('benchmark-comparison.json', jsonReport);
|
||||
|
||||
// Output summary to console
|
||||
console.log(markdownReport);
|
||||
|
||||
// Return exit code based on regressions
|
||||
if (comparison.summary.regressed > 0) {
|
||||
console.error(`\n❌ Found ${comparison.summary.regressed} performance regressions`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`\n✅ No performance regressions found`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length < 1) {
|
||||
console.error('Usage: node compare-benchmarks.js <current-results> [baseline-results]');
|
||||
console.error('If baseline-results is not provided, it will look for benchmark-baseline.json');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const currentPath = args[0];
|
||||
const baselinePath = args[1] || 'benchmark-baseline.json';
|
||||
|
||||
// Run comparison
|
||||
const comparator = new BenchmarkComparator();
|
||||
comparator.compare(currentPath, baselinePath).catch(console.error);
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Formats Vitest benchmark results for github-action-benchmark
|
||||
* Converts from Vitest format to the expected format
|
||||
*/
|
||||
function formatBenchmarkResults() {
|
||||
const resultsPath = path.join(process.cwd(), 'benchmark-results.json');
|
||||
|
||||
if (!fs.existsSync(resultsPath)) {
|
||||
console.error('benchmark-results.json not found');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const vitestResults = JSON.parse(fs.readFileSync(resultsPath, 'utf8'));
|
||||
|
||||
// Convert to github-action-benchmark format
|
||||
const formattedResults = [];
|
||||
|
||||
// Vitest benchmark JSON reporter format
|
||||
if (vitestResults.files) {
|
||||
for (const file of vitestResults.files) {
|
||||
const suiteName = path.basename(file.filepath, '.bench.ts');
|
||||
|
||||
// Process each suite in the file
|
||||
if (file.groups) {
|
||||
for (const group of file.groups) {
|
||||
for (const benchmark of group.benchmarks || []) {
|
||||
if (benchmark.result) {
|
||||
formattedResults.push({
|
||||
name: `${suiteName} - ${benchmark.name}`,
|
||||
unit: 'ms',
|
||||
value: benchmark.result.mean || 0,
|
||||
range: (benchmark.result.max - benchmark.result.min) || 0,
|
||||
extra: `${benchmark.result.hz?.toFixed(0) || 0} ops/sec`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (Array.isArray(vitestResults)) {
|
||||
// Alternative format handling
|
||||
for (const result of vitestResults) {
|
||||
if (result.name && result.result) {
|
||||
formattedResults.push({
|
||||
name: result.name,
|
||||
unit: 'ms',
|
||||
value: result.result.mean || 0,
|
||||
range: (result.result.max - result.result.min) || 0,
|
||||
extra: `${result.result.hz?.toFixed(0) || 0} ops/sec`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write formatted results
|
||||
const outputPath = path.join(process.cwd(), 'benchmark-results-formatted.json');
|
||||
fs.writeFileSync(outputPath, JSON.stringify(formattedResults, null, 2));
|
||||
|
||||
// Also create a summary for PR comments
|
||||
const summary = {
|
||||
timestamp: new Date().toISOString(),
|
||||
benchmarks: formattedResults.map(b => ({
|
||||
name: b.name,
|
||||
time: `${b.value.toFixed(3)}ms`,
|
||||
opsPerSec: b.extra,
|
||||
range: `±${(b.range / 2).toFixed(3)}ms`
|
||||
}))
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(process.cwd(), 'benchmark-summary.json'),
|
||||
JSON.stringify(summary, null, 2)
|
||||
);
|
||||
|
||||
console.log(`Formatted ${formattedResults.length} benchmark results`);
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
formatBenchmarkResults();
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Generates a stub benchmark-results.json file when benchmarks fail to produce output.
|
||||
* This ensures the CI pipeline doesn't fail due to missing files.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const stubResults = {
|
||||
timestamp: new Date().toISOString(),
|
||||
files: [
|
||||
{
|
||||
filepath: 'tests/benchmarks/stub.bench.ts',
|
||||
groups: [
|
||||
{
|
||||
name: 'Stub Benchmarks',
|
||||
benchmarks: [
|
||||
{
|
||||
name: 'stub-benchmark',
|
||||
result: {
|
||||
mean: 0.001,
|
||||
min: 0.001,
|
||||
max: 0.001,
|
||||
hz: 1000,
|
||||
p75: 0.001,
|
||||
p99: 0.001,
|
||||
p995: 0.001,
|
||||
p999: 0.001,
|
||||
rme: 0,
|
||||
samples: 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const outputPath = path.join(process.cwd(), 'benchmark-results.json');
|
||||
fs.writeFileSync(outputPath, JSON.stringify(stubResults, null, 2));
|
||||
console.log(`Generated stub benchmark results at ${outputPath}`);
|
||||
@@ -1,172 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const benchmarkResults = {
|
||||
timestamp: new Date().toISOString(),
|
||||
files: []
|
||||
};
|
||||
|
||||
// Function to strip ANSI color codes
|
||||
function stripAnsi(str) {
|
||||
return str.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
// Run vitest bench command with no color output for easier parsing
|
||||
const vitest = spawn('npx', ['vitest', 'bench', '--run', '--config', 'vitest.config.benchmark.ts', '--no-color'], {
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
shell: true,
|
||||
env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }
|
||||
});
|
||||
|
||||
let output = '';
|
||||
let currentFile = null;
|
||||
let currentSuite = null;
|
||||
|
||||
vitest.stdout.on('data', (data) => {
|
||||
const text = stripAnsi(data.toString());
|
||||
output += text;
|
||||
process.stdout.write(data); // Write original with colors
|
||||
|
||||
// Parse the output to extract benchmark results
|
||||
const lines = text.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
// Detect test file - match with or without checkmark
|
||||
const fileMatch = line.match(/[✓ ]\s+(tests\/benchmarks\/[^>]+\.bench\.ts)/);
|
||||
if (fileMatch) {
|
||||
console.log(`\n[Parser] Found file: ${fileMatch[1]}`);
|
||||
currentFile = {
|
||||
filepath: fileMatch[1],
|
||||
groups: []
|
||||
};
|
||||
benchmarkResults.files.push(currentFile);
|
||||
currentSuite = null;
|
||||
}
|
||||
|
||||
// Detect suite name
|
||||
const suiteMatch = line.match(/^\s+·\s+(.+?)\s+[\d,]+\.\d+\s+/);
|
||||
if (suiteMatch && currentFile) {
|
||||
const suiteName = suiteMatch[1].trim();
|
||||
|
||||
// Check if this is part of the previous line's suite description
|
||||
const lastLineMatch = lines[lines.indexOf(line) - 1]?.match(/>\s+(.+?)(?:\s+\d+ms)?$/);
|
||||
if (lastLineMatch) {
|
||||
currentSuite = {
|
||||
name: lastLineMatch[1].trim(),
|
||||
benchmarks: []
|
||||
};
|
||||
currentFile.groups.push(currentSuite);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse benchmark result line - the format is: name hz min max mean p75 p99 p995 p999 rme samples
|
||||
const benchMatch = line.match(/^\s*[·•]\s+(.+?)\s+([\d,]+\.\d+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+±([\d.]+)%\s+([\d,]+)/);
|
||||
if (benchMatch && currentFile) {
|
||||
const [, name, hz, min, max, mean, p75, p99, p995, p999, rme, samples] = benchMatch;
|
||||
console.log(`[Parser] Found benchmark: ${name.trim()}`);
|
||||
|
||||
|
||||
const benchmark = {
|
||||
name: name.trim(),
|
||||
result: {
|
||||
hz: parseFloat(hz.replace(/,/g, '')),
|
||||
min: parseFloat(min),
|
||||
max: parseFloat(max),
|
||||
mean: parseFloat(mean),
|
||||
p75: parseFloat(p75),
|
||||
p99: parseFloat(p99),
|
||||
p995: parseFloat(p995),
|
||||
p999: parseFloat(p999),
|
||||
rme: parseFloat(rme),
|
||||
samples: parseInt(samples.replace(/,/g, ''))
|
||||
}
|
||||
};
|
||||
|
||||
// Add to current suite or create a default one
|
||||
if (!currentSuite) {
|
||||
currentSuite = {
|
||||
name: 'Default',
|
||||
benchmarks: []
|
||||
};
|
||||
currentFile.groups.push(currentSuite);
|
||||
}
|
||||
|
||||
currentSuite.benchmarks.push(benchmark);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
vitest.stderr.on('data', (data) => {
|
||||
process.stderr.write(data);
|
||||
});
|
||||
|
||||
vitest.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
console.error(`Benchmark process exited with code ${code}`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
// Clean up empty files/groups
|
||||
benchmarkResults.files = benchmarkResults.files.filter(file =>
|
||||
file.groups.length > 0 && file.groups.some(group => group.benchmarks.length > 0)
|
||||
);
|
||||
|
||||
// Write results
|
||||
const outputPath = path.join(process.cwd(), 'benchmark-results.json');
|
||||
fs.writeFileSync(outputPath, JSON.stringify(benchmarkResults, null, 2));
|
||||
console.log(`\nBenchmark results written to ${outputPath}`);
|
||||
console.log(`Total files processed: ${benchmarkResults.files.length}`);
|
||||
|
||||
// Validate that we captured results
|
||||
let totalBenchmarks = 0;
|
||||
for (const file of benchmarkResults.files) {
|
||||
for (const group of file.groups) {
|
||||
totalBenchmarks += group.benchmarks.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (totalBenchmarks === 0) {
|
||||
console.warn('No benchmark results were captured! Generating stub results...');
|
||||
|
||||
// Generate stub results to prevent CI failure
|
||||
const stubResults = {
|
||||
timestamp: new Date().toISOString(),
|
||||
files: [
|
||||
{
|
||||
filepath: 'tests/benchmarks/sample.bench.ts',
|
||||
groups: [
|
||||
{
|
||||
name: 'Sample Benchmarks',
|
||||
benchmarks: [
|
||||
{
|
||||
name: 'array sorting - small',
|
||||
result: {
|
||||
mean: 0.0136,
|
||||
min: 0.0124,
|
||||
max: 0.3220,
|
||||
hz: 73341.27,
|
||||
p75: 0.0133,
|
||||
p99: 0.0213,
|
||||
p995: 0.0307,
|
||||
p999: 0.1062,
|
||||
rme: 0.51,
|
||||
samples: 36671
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(stubResults, null, 2));
|
||||
console.log('Stub results generated to prevent CI failure');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Total benchmarks captured: ${totalBenchmarks}`);
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
const { writeFileSync } = require('fs');
|
||||
const { resolve } = require('path');
|
||||
|
||||
class BenchmarkJsonReporter {
|
||||
constructor() {
|
||||
this.results = [];
|
||||
console.log('[BenchmarkJsonReporter] Initialized');
|
||||
}
|
||||
|
||||
onInit(ctx) {
|
||||
console.log('[BenchmarkJsonReporter] onInit called');
|
||||
}
|
||||
|
||||
onCollected(files) {
|
||||
console.log('[BenchmarkJsonReporter] onCollected called with', files ? files.length : 0, 'files');
|
||||
}
|
||||
|
||||
onTaskUpdate(tasks) {
|
||||
console.log('[BenchmarkJsonReporter] onTaskUpdate called');
|
||||
}
|
||||
|
||||
onBenchmarkResult(file, benchmark) {
|
||||
console.log('[BenchmarkJsonReporter] onBenchmarkResult called for', benchmark.name);
|
||||
}
|
||||
|
||||
onFinished(files, errors) {
|
||||
console.log('[BenchmarkJsonReporter] onFinished called with', files ? files.length : 0, 'files');
|
||||
|
||||
const results = {
|
||||
timestamp: new Date().toISOString(),
|
||||
files: []
|
||||
};
|
||||
|
||||
try {
|
||||
for (const file of files || []) {
|
||||
if (!file) continue;
|
||||
|
||||
const fileResult = {
|
||||
filepath: file.filepath || file.name || 'unknown',
|
||||
groups: []
|
||||
};
|
||||
|
||||
// Handle both file.tasks and file.benchmarks
|
||||
const tasks = file.tasks || file.benchmarks || [];
|
||||
|
||||
// Process tasks/benchmarks
|
||||
for (const task of tasks) {
|
||||
if (task.type === 'suite' && task.tasks) {
|
||||
// This is a suite containing benchmarks
|
||||
const group = {
|
||||
name: task.name,
|
||||
benchmarks: []
|
||||
};
|
||||
|
||||
for (const benchmark of task.tasks) {
|
||||
if (benchmark.result?.benchmark) {
|
||||
group.benchmarks.push({
|
||||
name: benchmark.name,
|
||||
result: {
|
||||
mean: benchmark.result.benchmark.mean,
|
||||
min: benchmark.result.benchmark.min,
|
||||
max: benchmark.result.benchmark.max,
|
||||
hz: benchmark.result.benchmark.hz,
|
||||
p75: benchmark.result.benchmark.p75,
|
||||
p99: benchmark.result.benchmark.p99,
|
||||
p995: benchmark.result.benchmark.p995,
|
||||
p999: benchmark.result.benchmark.p999,
|
||||
rme: benchmark.result.benchmark.rme,
|
||||
samples: benchmark.result.benchmark.samples
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (group.benchmarks.length > 0) {
|
||||
fileResult.groups.push(group);
|
||||
}
|
||||
} else if (task.result?.benchmark) {
|
||||
// This is a direct benchmark (not in a suite)
|
||||
if (!fileResult.groups.length) {
|
||||
fileResult.groups.push({
|
||||
name: 'Default',
|
||||
benchmarks: []
|
||||
});
|
||||
}
|
||||
|
||||
fileResult.groups[0].benchmarks.push({
|
||||
name: task.name,
|
||||
result: {
|
||||
mean: task.result.benchmark.mean,
|
||||
min: task.result.benchmark.min,
|
||||
max: task.result.benchmark.max,
|
||||
hz: task.result.benchmark.hz,
|
||||
p75: task.result.benchmark.p75,
|
||||
p99: task.result.benchmark.p99,
|
||||
p995: task.result.benchmark.p995,
|
||||
p999: task.result.benchmark.p999,
|
||||
rme: task.result.benchmark.rme,
|
||||
samples: task.result.benchmark.samples
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (fileResult.groups.length > 0) {
|
||||
results.files.push(fileResult);
|
||||
}
|
||||
}
|
||||
|
||||
// Write results
|
||||
const outputPath = resolve(process.cwd(), 'benchmark-results.json');
|
||||
writeFileSync(outputPath, JSON.stringify(results, null, 2));
|
||||
console.log(`[BenchmarkJsonReporter] Benchmark results written to ${outputPath}`);
|
||||
console.log(`[BenchmarkJsonReporter] Total files processed: ${results.files.length}`);
|
||||
} catch (error) {
|
||||
console.error('[BenchmarkJsonReporter] Error writing results:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BenchmarkJsonReporter;
|
||||
@@ -1,100 +0,0 @@
|
||||
import type { Task, TaskResult, BenchmarkResult } from 'vitest';
|
||||
import { writeFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
interface BenchmarkJsonResult {
|
||||
timestamp: string;
|
||||
files: Array<{
|
||||
filepath: string;
|
||||
groups: Array<{
|
||||
name: string;
|
||||
benchmarks: Array<{
|
||||
name: string;
|
||||
result: {
|
||||
mean: number;
|
||||
min: number;
|
||||
max: number;
|
||||
hz: number;
|
||||
p75: number;
|
||||
p99: number;
|
||||
p995: number;
|
||||
p999: number;
|
||||
rme: number;
|
||||
samples: number;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class BenchmarkJsonReporter {
|
||||
private results: BenchmarkJsonResult = {
|
||||
timestamp: new Date().toISOString(),
|
||||
files: []
|
||||
};
|
||||
|
||||
onInit() {
|
||||
console.log('[BenchmarkJsonReporter] Initialized');
|
||||
}
|
||||
|
||||
onFinished(files?: Task[]) {
|
||||
console.log('[BenchmarkJsonReporter] onFinished called');
|
||||
|
||||
if (!files) {
|
||||
console.log('[BenchmarkJsonReporter] No files provided');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const fileResult = {
|
||||
filepath: file.filepath || 'unknown',
|
||||
groups: [] as any[]
|
||||
};
|
||||
|
||||
this.processTask(file, fileResult);
|
||||
|
||||
if (fileResult.groups.length > 0) {
|
||||
this.results.files.push(fileResult);
|
||||
}
|
||||
}
|
||||
|
||||
// Write results
|
||||
const outputPath = resolve(process.cwd(), 'benchmark-results.json');
|
||||
writeFileSync(outputPath, JSON.stringify(this.results, null, 2));
|
||||
console.log(`[BenchmarkJsonReporter] Results written to ${outputPath}`);
|
||||
}
|
||||
|
||||
private processTask(task: Task, fileResult: any) {
|
||||
if (task.type === 'suite' && task.tasks) {
|
||||
const group = {
|
||||
name: task.name,
|
||||
benchmarks: [] as any[]
|
||||
};
|
||||
|
||||
for (const benchmark of task.tasks) {
|
||||
const result = benchmark.result as TaskResult & { benchmark?: BenchmarkResult };
|
||||
if (result?.benchmark) {
|
||||
group.benchmarks.push({
|
||||
name: benchmark.name,
|
||||
result: {
|
||||
mean: result.benchmark.mean || 0,
|
||||
min: result.benchmark.min || 0,
|
||||
max: result.benchmark.max || 0,
|
||||
hz: result.benchmark.hz || 0,
|
||||
p75: result.benchmark.p75 || 0,
|
||||
p99: result.benchmark.p99 || 0,
|
||||
p995: result.benchmark.p995 || 0,
|
||||
p999: result.benchmark.p999 || 0,
|
||||
rme: result.benchmark.rme || 0,
|
||||
samples: result.benchmark.samples?.length || 0
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (group.benchmarks.length > 0) {
|
||||
fileResult.groups.push(group);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,8 @@ const DEFAULT_CONFIG: Required<Omit<DocumentationGeneratorConfig, 'baseUrl' | 't
|
||||
*/
|
||||
export class DocumentationGenerator {
|
||||
private client: OpenAI;
|
||||
private baseUrl: string;
|
||||
private apiKey: string;
|
||||
private model: string;
|
||||
private maxTokens: number;
|
||||
private timeout: number;
|
||||
@@ -85,6 +87,8 @@ export class DocumentationGenerator {
|
||||
constructor(config: DocumentationGeneratorConfig) {
|
||||
const fullConfig = { ...DEFAULT_CONFIG, ...config };
|
||||
|
||||
this.baseUrl = config.baseUrl;
|
||||
this.apiKey = fullConfig.apiKey;
|
||||
this.client = new OpenAI({
|
||||
baseURL: config.baseUrl,
|
||||
apiKey: fullConfig.apiKey,
|
||||
@@ -103,21 +107,10 @@ export class DocumentationGenerator {
|
||||
try {
|
||||
const prompt = this.buildPrompt(input);
|
||||
|
||||
const completion = await this.client.chat.completions.create({
|
||||
model: this.model,
|
||||
max_completion_tokens: this.maxTokens,
|
||||
...(this.temperature !== undefined ? { temperature: this.temperature } : {}),
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: this.getSystemPrompt(),
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
},
|
||||
],
|
||||
});
|
||||
const completion = await this.chatCompletion([
|
||||
{ role: 'system', content: this.getSystemPrompt() },
|
||||
{ role: 'user', content: prompt },
|
||||
], this.maxTokens);
|
||||
|
||||
const content = completion.choices[0]?.message?.content;
|
||||
if (!content) {
|
||||
@@ -246,20 +239,23 @@ Guidelines:
|
||||
* Extract JSON from LLM response (handles markdown code blocks)
|
||||
*/
|
||||
private extractJson(content: string): string {
|
||||
// Strip <think>...</think> blocks from thinking models (e.g., Qwen3-Thinking)
|
||||
const stripped = content.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
||||
|
||||
// Try to extract from markdown code block
|
||||
const jsonBlockMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
const jsonBlockMatch = stripped.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||
if (jsonBlockMatch) {
|
||||
return jsonBlockMatch[1].trim();
|
||||
}
|
||||
|
||||
// Try to find JSON object directly
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||
const jsonMatch = stripped.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
return jsonMatch[0];
|
||||
}
|
||||
|
||||
// Return as-is if no extraction needed
|
||||
return content.trim();
|
||||
return stripped;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,16 +319,9 @@ Guidelines:
|
||||
*/
|
||||
async testConnection(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const completion = await this.client.chat.completions.create({
|
||||
model: this.model,
|
||||
max_completion_tokens: 200,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
},
|
||||
],
|
||||
});
|
||||
const completion = await this.chatCompletion([
|
||||
{ role: 'user', content: 'Hello' },
|
||||
], 200);
|
||||
|
||||
if (completion.choices[0]?.message?.content) {
|
||||
return { success: true, message: `Connected to ${this.model}` };
|
||||
@@ -345,6 +334,44 @@ Guidelines:
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a chat completion request with chat_template_kwargs support for vLLM thinking models
|
||||
*/
|
||||
private async chatCompletion(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
maxTokens: number
|
||||
): Promise<{ choices: Array<{ message: { content: string | null } }> }> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.apiKey !== 'not-needed' ? { Authorization: `Bearer ${this.apiKey}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
messages,
|
||||
max_completion_tokens: maxTokens,
|
||||
...(this.temperature !== undefined ? { temperature: this.temperature } : {}),
|
||||
chat_template_kwargs: { enable_thinking: false },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`${response.status} ${text}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as { choices: Array<{ message: { content: string | null } }> };
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -34,6 +34,11 @@ export class NodeRepository {
|
||||
* Supports both core and community nodes via optional community fields
|
||||
*/
|
||||
saveNode(node: ParsedNode & Partial<CommunityNodeFields>): void {
|
||||
// Preserve existing npm_readme and ai_documentation_summary on upsert
|
||||
const existing = this.db.prepare(
|
||||
'SELECT npm_readme, ai_documentation_summary, ai_summary_generated_at FROM nodes WHERE node_type = ?'
|
||||
).get(node.nodeType) as { npm_readme?: string; ai_documentation_summary?: string; ai_summary_generated_at?: string } | undefined;
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT OR REPLACE INTO nodes (
|
||||
node_type, package_name, display_name, description,
|
||||
@@ -43,8 +48,9 @@ export class NodeRepository {
|
||||
properties_schema, operations, credentials_required,
|
||||
outputs, output_names,
|
||||
is_community, is_verified, author_name, author_github_url,
|
||||
npm_package_name, npm_version, npm_downloads, community_fetched_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
npm_package_name, npm_version, npm_downloads, community_fetched_at,
|
||||
npm_readme, ai_documentation_summary, ai_summary_generated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(
|
||||
@@ -76,7 +82,11 @@ export class NodeRepository {
|
||||
node.npmPackageName || null,
|
||||
node.npmVersion || null,
|
||||
node.npmDownloads || 0,
|
||||
node.communityFetchedAt || null
|
||||
node.communityFetchedAt || null,
|
||||
// Preserve existing docs data on upsert
|
||||
existing?.npm_readme || null,
|
||||
existing?.ai_documentation_summary || null,
|
||||
existing?.ai_summary_generated_at || null
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -107,11 +107,10 @@ export class SingleSessionHTTPServer {
|
||||
private session: Session | null = null; // Keep for SSE compatibility
|
||||
private consoleManager = new ConsoleManager();
|
||||
private expressServer: any;
|
||||
// Session timeout reduced from 30 minutes to 5 minutes for faster cleanup
|
||||
// Configurable via SESSION_TIMEOUT_MINUTES environment variable
|
||||
// This prevents memory buildup from stale sessions
|
||||
// Session timeout — configurable via SESSION_TIMEOUT_MINUTES environment variable
|
||||
// Default 30 minutes: balances memory cleanup with real editing sessions (#626)
|
||||
private sessionTimeout = parseInt(
|
||||
process.env.SESSION_TIMEOUT_MINUTES || '5', 10
|
||||
process.env.SESSION_TIMEOUT_MINUTES || '30', 10
|
||||
) * 60 * 1000;
|
||||
private authToken: string | null = null;
|
||||
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -2731,7 +2731,7 @@ const updateTableSchema = tableIdSchema.extend({
|
||||
|
||||
// MCP transports may serialize JSON objects/arrays as strings.
|
||||
// Parse them back, but return the original value on failure so Zod reports a proper type error.
|
||||
function tryParseJson(val: unknown): unknown {
|
||||
export function tryParseJson(val: unknown): unknown {
|
||||
if (typeof val !== 'string') return val;
|
||||
try { return JSON.parse(val); } catch { return val; }
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { z } from 'zod';
|
||||
import { McpToolResponse } from '../types/n8n-api';
|
||||
import { WorkflowDiffRequest, WorkflowDiffOperation, WorkflowDiffValidationError } from '../types/workflow-diff';
|
||||
import { WorkflowDiffEngine } from '../services/workflow-diff-engine';
|
||||
import { getN8nApiClient } from './handlers-n8n-manager';
|
||||
import { getN8nApiClient, tryParseJson } from './handlers-n8n-manager';
|
||||
import { N8nApiError, getUserFriendlyErrorMessage } from '../utils/n8n-errors';
|
||||
import { logger } from '../utils/logger';
|
||||
import { InstanceContext } from '../types/instance-context';
|
||||
@@ -39,7 +39,7 @@ const NODE_TARGETING_OPERATIONS = new Set([
|
||||
// Zod schema for the diff request
|
||||
const workflowDiffSchema = z.object({
|
||||
id: z.string(),
|
||||
operations: z.array(z.object({
|
||||
operations: z.preprocess(tryParseJson, z.array(z.object({
|
||||
type: z.string(),
|
||||
description: z.string().optional(),
|
||||
// Node operations
|
||||
@@ -87,7 +87,7 @@ const workflowDiffSchema = z.object({
|
||||
}
|
||||
}
|
||||
return op;
|
||||
})),
|
||||
}))),
|
||||
validateOnly: z.boolean().optional(),
|
||||
continueOnError: z.boolean().optional(),
|
||||
createBackup: z.boolean().optional(),
|
||||
@@ -259,9 +259,9 @@ export async function handleUpdatePartialWorkflow(
|
||||
// Build recovery guidance based on error types
|
||||
const recoverySteps = [];
|
||||
if (errorTypes.has('operator_issues')) {
|
||||
recoverySteps.push('Operator structure issue detected. Use validate_node_operation to check specific nodes.');
|
||||
recoverySteps.push('Operator structure issue detected. Use validate_node to check specific nodes.');
|
||||
recoverySteps.push('Binary operators (equals, contains, greaterThan, etc.) must NOT have singleValue:true');
|
||||
recoverySteps.push('Unary operators (isEmpty, isNotEmpty, true, false) REQUIRE singleValue:true');
|
||||
recoverySteps.push('Unary operators (empty, notEmpty, true, false) REQUIRE singleValue:true');
|
||||
}
|
||||
if (errorTypes.has('connection_issues')) {
|
||||
recoverySteps.push('Connection validation failed. Check all node connections reference existing nodes.');
|
||||
|
||||
@@ -748,6 +748,13 @@ export class N8NDocumentationMCPServer {
|
||||
// tool's inputSchema as the source of truth.
|
||||
processedArgs = this.coerceStringifiedJsonParams(name, processedArgs);
|
||||
|
||||
// Strip undefined values from args (#611) — VS Code extension sends
|
||||
// explicit undefined values which Zod's .optional() rejects.
|
||||
// Removing them makes Zod treat them as missing (which .optional() allows).
|
||||
if (processedArgs) {
|
||||
processedArgs = JSON.parse(JSON.stringify(processedArgs));
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug(`Executing tool: ${name}`, { args: processedArgs });
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -39,6 +39,25 @@ console.clear = () => {};
|
||||
console.count = () => {};
|
||||
console.countReset = () => {};
|
||||
|
||||
// CRITICAL: Intercept process.stdout.write to prevent non-JSON-RPC output (#628, #627, #567)
|
||||
// Console suppression alone is insufficient — native modules (better-sqlite3), n8n packages,
|
||||
// and third-party code can call process.stdout.write() directly, corrupting the JSON-RPC stream.
|
||||
// Only allow writes that look like JSON-RPC messages; redirect everything else to stderr.
|
||||
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||
const stderrWrite = process.stderr.write.bind(process.stderr);
|
||||
|
||||
process.stdout.write = function (chunk: any, encodingOrCallback?: any, callback?: any): boolean {
|
||||
const str = typeof chunk === 'string' ? chunk : chunk.toString();
|
||||
// JSON-RPC messages are JSON objects with "jsonrpc" field — let those through
|
||||
// The MCP SDK sends one JSON object per write call
|
||||
const trimmed = str.trimStart();
|
||||
if (trimmed.startsWith('{') && trimmed.includes('"jsonrpc"')) {
|
||||
return originalStdoutWrite(chunk, encodingOrCallback, callback);
|
||||
}
|
||||
// Redirect everything else to stderr so it doesn't corrupt the protocol
|
||||
return stderrWrite(chunk, encodingOrCallback, callback);
|
||||
} as typeof process.stdout.write;
|
||||
|
||||
// Import and run the server AFTER suppressing output
|
||||
import { N8NDocumentationMCPServer } from './server';
|
||||
|
||||
|
||||
@@ -119,8 +119,8 @@ When ANY workflow update is made, ALL nodes in the workflow are automatically sa
|
||||
|
||||
1. **Operator Structure Fixes**:
|
||||
- Binary operators (equals, contains, greaterThan, etc.) automatically have \`singleValue\` removed
|
||||
- Unary operators (isEmpty, isNotEmpty, true, false) automatically get \`singleValue: true\` added
|
||||
- Invalid operator structures (e.g., \`{type: "isNotEmpty"}\`) are corrected to \`{type: "boolean", operation: "isNotEmpty"}\`
|
||||
- Unary operators (empty, notEmpty, true, false) automatically get \`singleValue: true\` added
|
||||
- Invalid operator structures (e.g., \`{type: "notEmpty"}\`) are corrected to \`{type: "object", operation: "notEmpty"}\`
|
||||
|
||||
2. **Missing Metadata Added**:
|
||||
- IF nodes with conditions get complete \`conditions.options\` structure if missing
|
||||
@@ -333,6 +333,8 @@ n8n_update_partial_workflow({
|
||||
'// Best-effort mode: apply what works, report what fails\nn8n_update_partial_workflow({id: "vwx", operations: [\n {type: "updateName", name: "Fixed Workflow"},\n {type: "removeConnection", source: "Broken", target: "Node"},\n {type: "cleanStaleConnections"}\n], continueOnError: true})',
|
||||
'// Update node parameter\nn8n_update_partial_workflow({id: "yza", operations: [{type: "updateNode", nodeName: "HTTP Request", updates: {"parameters.url": "https://api.example.com"}}]})',
|
||||
'// Validate before applying\nn8n_update_partial_workflow({id: "bcd", operations: [{type: "removeNode", nodeName: "Old Process"}], validateOnly: true})',
|
||||
'// Surgically edit code using __patch_find_replace (avoids replacing entire code block)\nn8n_update_partial_workflow({id: "pfr1", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "const limit = 10;", "replace": "const limit = 50;"}]}}}]})',
|
||||
'// Multiple sequential patches on the same property\nn8n_update_partial_workflow({id: "pfr2", operations: [{type: "updateNode", nodeName: "Code", updates: {"parameters.jsCode": {"__patch_find_replace": [{"find": "api.old-domain.com", "replace": "api.new-domain.com"}, {"find": "Authorization: Bearer old_token", "replace": "Authorization: Bearer new_token"}]}}}]})',
|
||||
'\n// ============ AI CONNECTION EXAMPLES ============',
|
||||
'// Connect language model to AI Agent\nn8n_update_partial_workflow({id: "ai1", operations: [{type: "addConnection", source: "OpenAI Chat Model", target: "AI Agent", sourceOutput: "ai_languageModel"}]})',
|
||||
'// Connect tool to AI Agent\nn8n_update_partial_workflow({id: "ai2", operations: [{type: "addConnection", source: "HTTP Request Tool", target: "AI Agent", sourceOutput: "ai_tool"}]})',
|
||||
@@ -411,10 +413,12 @@ n8n_update_partial_workflow({
|
||||
'**CRITICAL**: For Switch nodes, ALWAYS use case=N instead of sourceIndex. Using same sourceIndex for multiple connections will put them on the same case output.',
|
||||
'cleanStaleConnections removes ALL broken connections - cannot be selective',
|
||||
'replaceConnections overwrites entire connections object - all previous connections lost',
|
||||
'**Auto-sanitization behavior**: Binary operators (equals, contains) automatically have singleValue removed; unary operators (isEmpty, isNotEmpty) automatically get singleValue:true added',
|
||||
'**Auto-sanitization behavior**: Binary operators (equals, contains) automatically have singleValue removed; unary operators (empty, notEmpty) automatically get singleValue:true added',
|
||||
'**Auto-sanitization runs on ALL nodes**: When ANY update is made, ALL nodes in the workflow are sanitized (not just modified ones)',
|
||||
'**Auto-sanitization cannot fix everything**: It fixes operator structures and missing metadata, but cannot fix broken connections or branch mismatches',
|
||||
'**Corrupted workflows beyond repair**: Workflows in paradoxical states (API returns corrupt, API rejects updates) cannot be fixed via API - must be recreated',
|
||||
'**__patch_find_replace for code edits**: Instead of replacing entire code blocks, use `{"parameters.jsCode": {"__patch_find_replace": [{"find": "old text", "replace": "new text"}]}}` to surgically edit string properties',
|
||||
'__patch_find_replace replaces the FIRST occurrence of each find string. Patches are applied sequentially — order matters',
|
||||
'To remove a property, set it to null in the updates object',
|
||||
'When properties are mutually exclusive (e.g., continueOnFail and onError), setting only the new property will fail - you must remove the old one with null',
|
||||
'Removing a required property may cause validation errors - check node documentation first',
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
* Fetch community nodes from n8n Strapi API and npm registry.
|
||||
*
|
||||
* Usage:
|
||||
* npm run fetch:community # Full rebuild (verified + top 100 npm)
|
||||
* npm run fetch:community # Upsert all (preserves READMEs and AI summaries)
|
||||
* npm run fetch:community:verified # Verified nodes only (fast)
|
||||
* npm run fetch:community:update # Incremental update (skip existing)
|
||||
*
|
||||
* Options:
|
||||
* --verified-only Only fetch verified nodes from Strapi API
|
||||
* --update Skip nodes that already exist in database
|
||||
* --rebuild Delete all community nodes first (wipes READMEs/AI summaries!)
|
||||
* --npm-limit=N Maximum number of npm packages to fetch (default: 100)
|
||||
* --staging Use staging Strapi API instead of production
|
||||
*/
|
||||
@@ -22,6 +23,7 @@ import { createDatabaseAdapter } from '../database/database-adapter';
|
||||
interface CliOptions {
|
||||
verifiedOnly: boolean;
|
||||
update: boolean;
|
||||
rebuild: boolean;
|
||||
npmLimit: number;
|
||||
staging: boolean;
|
||||
}
|
||||
@@ -32,6 +34,7 @@ function parseArgs(): CliOptions {
|
||||
const options: CliOptions = {
|
||||
verifiedOnly: false,
|
||||
update: false,
|
||||
rebuild: false,
|
||||
npmLimit: 100,
|
||||
staging: false,
|
||||
};
|
||||
@@ -41,6 +44,8 @@ function parseArgs(): CliOptions {
|
||||
options.verifiedOnly = true;
|
||||
} else if (arg === '--update') {
|
||||
options.update = true;
|
||||
} else if (arg === '--rebuild') {
|
||||
options.rebuild = true;
|
||||
} else if (arg === '--staging') {
|
||||
options.staging = true;
|
||||
} else if (arg.startsWith('--npm-limit=')) {
|
||||
@@ -73,7 +78,7 @@ async function main(): Promise<void> {
|
||||
|
||||
// Print options
|
||||
console.log('Options:');
|
||||
console.log(` - Mode: ${cliOptions.update ? 'Update (incremental)' : 'Rebuild'}`);
|
||||
console.log(` - Mode: ${cliOptions.rebuild ? 'Rebuild (clean slate)' : cliOptions.update ? 'Update (skip existing)' : 'Upsert (preserves docs)'}`);
|
||||
console.log(` - Verified only: ${cliOptions.verifiedOnly ? 'Yes' : 'No'}`);
|
||||
if (!cliOptions.verifiedOnly) {
|
||||
console.log(` - npm package limit: ${cliOptions.npmLimit}`);
|
||||
@@ -92,9 +97,10 @@ async function main(): Promise<void> {
|
||||
const environment = cliOptions.staging ? 'staging' : 'production';
|
||||
const service = new CommunityNodeService(repository, environment);
|
||||
|
||||
// If not updating, delete existing community nodes
|
||||
if (!cliOptions.update) {
|
||||
console.log('\nClearing existing community nodes...');
|
||||
// Only delete existing community nodes when --rebuild is explicitly requested
|
||||
if (cliOptions.rebuild) {
|
||||
console.log('\nClearing existing community nodes (--rebuild)...');
|
||||
console.log(' WARNING: This wipes READMEs and AI summaries!');
|
||||
const deleted = service.deleteCommunityNodes();
|
||||
console.log(` Deleted ${deleted} existing community nodes`);
|
||||
}
|
||||
|
||||
@@ -124,7 +124,15 @@ async function rebuild() {
|
||||
}
|
||||
|
||||
console.log(`💾 Save completed: ${saved} nodes saved successfully`);
|
||||
|
||||
|
||||
// Rebuild FTS5 index to guarantee consistency.
|
||||
// The content-synced FTS5 table (content=nodes) can accumulate stale rowid
|
||||
// references when rows are deleted and re-inserted during a rebuild cycle.
|
||||
// An explicit rebuild re-indexes all current rows from the nodes table.
|
||||
console.log('\n🔍 Rebuilding FTS5 search index...');
|
||||
db.prepare("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')").run();
|
||||
console.log('✅ FTS5 index rebuilt successfully');
|
||||
|
||||
// Validation check
|
||||
console.log('\n🔍 Running validation checks...');
|
||||
try {
|
||||
|
||||
@@ -1209,30 +1209,30 @@ export class EnhancedConfigValidator extends ConfigValidator {
|
||||
'empty', 'notEmpty', 'equals', 'notEquals',
|
||||
'contains', 'notContains', 'startsWith', 'notStartsWith',
|
||||
'endsWith', 'notEndsWith', 'regex', 'notRegex',
|
||||
'exists', 'notExists', 'isNotEmpty' // exists checks field presence, isNotEmpty alias for notEmpty
|
||||
'exists', 'notExists'
|
||||
],
|
||||
number: [
|
||||
'empty', 'notEmpty', 'equals', 'notEquals', 'gt', 'lt', 'gte', 'lte',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
'exists', 'notExists'
|
||||
],
|
||||
dateTime: [
|
||||
'empty', 'notEmpty', 'equals', 'notEquals', 'after', 'before', 'afterOrEquals', 'beforeOrEquals',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
'exists', 'notExists'
|
||||
],
|
||||
boolean: [
|
||||
'empty', 'notEmpty', 'true', 'false', 'equals', 'notEquals',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
'exists', 'notExists'
|
||||
],
|
||||
array: [
|
||||
'contains', 'notContains', 'lengthEquals', 'lengthNotEquals',
|
||||
'lengthGt', 'lengthLt', 'lengthGte', 'lengthLte', 'empty', 'notEmpty',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
'exists', 'notExists'
|
||||
],
|
||||
object: [
|
||||
'empty', 'notEmpty',
|
||||
'exists', 'notExists', 'isNotEmpty'
|
||||
'exists', 'notExists'
|
||||
],
|
||||
any: ['exists', 'notExists', 'isNotEmpty']
|
||||
any: ['exists', 'notExists']
|
||||
};
|
||||
|
||||
for (let i = 0; i < conditions.length; i++) {
|
||||
|
||||
@@ -621,13 +621,13 @@ export function validateOperatorStructure(operator: any, path: string): string[]
|
||||
if (!operator.operation) {
|
||||
errors.push(
|
||||
`${path}: missing required field "operation". ` +
|
||||
'Operation specifies the comparison type (e.g., "equals", "contains", "isNotEmpty")'
|
||||
'Operation specifies the comparison type (e.g., "equals", "contains", "notEmpty")'
|
||||
);
|
||||
}
|
||||
|
||||
// Check singleValue based on operator type
|
||||
if (operator.operation) {
|
||||
const unaryOperators = ['isEmpty', 'isNotEmpty', 'true', 'false', 'isNumeric'];
|
||||
const unaryOperators = ['empty', 'notEmpty', 'true', 'false', 'isNumeric', 'exists', 'notExists'];
|
||||
const isUnary = unaryOperators.includes(operator.operation);
|
||||
|
||||
if (isUnary) {
|
||||
@@ -643,7 +643,7 @@ export function validateOperatorStructure(operator: any, path: string): string[]
|
||||
if (operator.singleValue === true) {
|
||||
errors.push(
|
||||
`${path}: binary operator "${operator.operation}" should not have "singleValue: true". ` +
|
||||
'Only unary operators (isEmpty, isNotEmpty, true, false, isNumeric) need this property.'
|
||||
'Only unary operators (empty, notEmpty, true, false, isNumeric, exists, notExists) need this property.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,23 @@ import { INodeParameters } from 'n8n-workflow';
|
||||
import { logger } from '../utils/logger';
|
||||
import { WorkflowNode } from '../types/n8n-api';
|
||||
|
||||
/** Legacy operator names that n8n no longer recognizes, mapped to their correct names. */
|
||||
const OPERATOR_CORRECTIONS: Record<string, string> = {
|
||||
'isEmpty': 'empty',
|
||||
'isNotEmpty': 'notEmpty',
|
||||
};
|
||||
|
||||
/** Operators that take no right-hand value and require singleValue: true. */
|
||||
const UNARY_OPERATORS = new Set([
|
||||
'true',
|
||||
'false',
|
||||
'isNumeric',
|
||||
'empty',
|
||||
'notEmpty',
|
||||
'exists',
|
||||
'notExists',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Sanitize a single node by adding required metadata
|
||||
*/
|
||||
@@ -162,29 +179,28 @@ function sanitizeOperator(operator: any): any {
|
||||
const sanitized = { ...operator };
|
||||
|
||||
// Fix common mistake: type field used for operation name
|
||||
// WRONG: {type: "isNotEmpty"}
|
||||
// RIGHT: {type: "string", operation: "isNotEmpty"}
|
||||
// WRONG: {type: "notEmpty"}
|
||||
// RIGHT: {type: "string", operation: "notEmpty"}
|
||||
if (sanitized.type && !sanitized.operation) {
|
||||
// Check if type value looks like an operation (lowercase, no dots)
|
||||
const typeValue = sanitized.type as string;
|
||||
if (isOperationName(typeValue)) {
|
||||
logger.debug(`Fixing operator structure: converting type="${typeValue}" to operation`);
|
||||
|
||||
// Infer data type from operation
|
||||
const dataType = inferDataType(typeValue);
|
||||
sanitized.type = dataType;
|
||||
sanitized.type = inferDataType(typeValue);
|
||||
sanitized.operation = typeValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-correct legacy operator names to n8n-recognized names
|
||||
if (sanitized.operation && OPERATOR_CORRECTIONS[sanitized.operation]) {
|
||||
sanitized.operation = OPERATOR_CORRECTIONS[sanitized.operation];
|
||||
}
|
||||
|
||||
// Set singleValue based on operator type
|
||||
if (sanitized.operation) {
|
||||
if (isUnaryOperator(sanitized.operation)) {
|
||||
// Unary operators require singleValue: true
|
||||
sanitized.singleValue = true;
|
||||
} else {
|
||||
// Binary operators should NOT have singleValue (or it should be false/undefined)
|
||||
// Remove it to prevent UI errors
|
||||
// Binary operators should NOT have singleValue — remove it to prevent UI errors
|
||||
delete sanitized.singleValue;
|
||||
}
|
||||
}
|
||||
@@ -207,7 +223,7 @@ function isOperationName(value: string): boolean {
|
||||
*/
|
||||
function inferDataType(operation: string): string {
|
||||
// Boolean operations
|
||||
const booleanOps = ['true', 'false', 'isEmpty', 'isNotEmpty'];
|
||||
const booleanOps = ['true', 'false'];
|
||||
if (booleanOps.includes(operation)) {
|
||||
return 'boolean';
|
||||
}
|
||||
@@ -225,7 +241,6 @@ function inferDataType(operation: string): string {
|
||||
}
|
||||
|
||||
// Object operations: empty/notEmpty/exists/notExists are generic object-level checks
|
||||
// (distinct from isEmpty/isNotEmpty which are boolean-typed operations)
|
||||
const objectOps = ['empty', 'notEmpty', 'exists', 'notExists'];
|
||||
if (objectOps.includes(operation)) {
|
||||
return 'object';
|
||||
@@ -239,18 +254,7 @@ function inferDataType(operation: string): string {
|
||||
* Check if operator is unary (requires singleValue: true)
|
||||
*/
|
||||
function isUnaryOperator(operation: string): boolean {
|
||||
const unaryOps = [
|
||||
'isEmpty',
|
||||
'isNotEmpty',
|
||||
'true',
|
||||
'false',
|
||||
'isNumeric',
|
||||
'empty',
|
||||
'notEmpty',
|
||||
'exists',
|
||||
'notExists'
|
||||
];
|
||||
return unaryOps.includes(operation);
|
||||
return UNARY_OPERATORS.has(operation);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -470,6 +470,31 @@ export class WorkflowDiffEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate __patch_find_replace syntax (#642)
|
||||
for (const [path, value] of Object.entries(operation.updates)) {
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
&& '__patch_find_replace' in value) {
|
||||
const patches = value.__patch_find_replace;
|
||||
if (!Array.isArray(patches)) {
|
||||
return `Invalid __patch_find_replace at "${path}": must be an array of {find, replace} objects`;
|
||||
}
|
||||
for (let i = 0; i < patches.length; i++) {
|
||||
const patch = patches[i];
|
||||
if (!patch || typeof patch.find !== 'string' || typeof patch.replace !== 'string') {
|
||||
return `Invalid __patch_find_replace entry at "${path}[${i}]": each entry must have "find" (string) and "replace" (string)`;
|
||||
}
|
||||
}
|
||||
// node was already found above — reuse it
|
||||
const currentValue = this.getNestedProperty(node, path);
|
||||
if (currentValue === undefined) {
|
||||
return `Cannot apply __patch_find_replace to "${path}": property does not exist on node`;
|
||||
}
|
||||
if (typeof currentValue !== 'string') {
|
||||
return `Cannot apply __patch_find_replace to "${path}": current value is ${typeof currentValue}, expected string`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -721,7 +746,26 @@ export class WorkflowDiffEngine {
|
||||
|
||||
// Apply updates using dot notation
|
||||
Object.entries(operation.updates).forEach(([path, value]) => {
|
||||
this.setNestedProperty(node, path, value);
|
||||
// Handle __patch_find_replace for surgical string edits (#642)
|
||||
// Format and type validation already passed in validateUpdateNode()
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
&& '__patch_find_replace' in value) {
|
||||
const patches = value.__patch_find_replace as Array<{ find: string; replace: string }>;
|
||||
let current = this.getNestedProperty(node, path) as string;
|
||||
for (const patch of patches) {
|
||||
if (!current.includes(patch.find)) {
|
||||
this.warnings.push({
|
||||
operation: -1,
|
||||
message: `__patch_find_replace: "${patch.find.substring(0, 50)}" not found in "${path}". Skipped.`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
current = current.replace(patch.find, patch.replace);
|
||||
}
|
||||
this.setNestedProperty(node, path, current);
|
||||
} else {
|
||||
this.setNestedProperty(node, path, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Sanitize node after updates to ensure metadata is complete
|
||||
@@ -766,11 +810,13 @@ export class WorkflowDiffEngine {
|
||||
let sourceOutput = String(operation.sourceOutput ?? 'main');
|
||||
let sourceIndex = operation.sourceIndex ?? 0;
|
||||
|
||||
// Remap numeric sourceOutput (e.g., "0", "1") to "main" with sourceIndex (#537)
|
||||
// Remap numeric sourceOutput (e.g., "0", "1") to "main" with sourceIndex (#537, #659)
|
||||
// Skip when smart parameters (branch, case) are present — they take precedence
|
||||
if (/^\d+$/.test(sourceOutput) && operation.sourceIndex === undefined
|
||||
const numericOutput = /^\d+$/.test(sourceOutput) ? parseInt(sourceOutput, 10) : null;
|
||||
if (numericOutput !== null
|
||||
&& (operation.sourceIndex === undefined || operation.sourceIndex === numericOutput)
|
||||
&& operation.branch === undefined && operation.case === undefined) {
|
||||
sourceIndex = parseInt(sourceOutput, 10);
|
||||
sourceIndex = numericOutput;
|
||||
sourceOutput = 'main';
|
||||
}
|
||||
|
||||
@@ -823,7 +869,11 @@ export class WorkflowDiffEngine {
|
||||
// Use nullish coalescing to properly handle explicit 0 values
|
||||
// Default targetInput to sourceOutput to preserve connection type for AI connections (ai_tool, ai_memory, etc.)
|
||||
// Coerce to string to handle numeric values passed as sourceOutput/targetInput
|
||||
const targetInput = String(operation.targetInput ?? sourceOutput);
|
||||
let targetInput = String(operation.targetInput ?? sourceOutput);
|
||||
// Remap numeric targetInput (e.g., "0") to "main" — connection types are named strings (#659)
|
||||
if (/^\d+$/.test(targetInput)) {
|
||||
targetInput = 'main';
|
||||
}
|
||||
const targetIndex = operation.targetIndex ?? 0;
|
||||
|
||||
// Initialize source node connections object
|
||||
@@ -1266,6 +1316,16 @@ export class WorkflowDiffEngine {
|
||||
return `Node not found for ${operationType}: "${nodeIdentifier}". Available nodes: ${availableNodes}. Tip: Use node ID for names with special characters (apostrophes, quotes).`;
|
||||
}
|
||||
|
||||
private getNestedProperty(obj: any, path: string): any {
|
||||
const keys = path.split('.');
|
||||
let current = obj;
|
||||
for (const key of keys) {
|
||||
if (current == null || typeof current !== 'object') return undefined;
|
||||
current = current[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private setNestedProperty(obj: any, path: string, value: any): void {
|
||||
const keys = path.split('.');
|
||||
let current = obj;
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
# Performance Benchmarks
|
||||
|
||||
This directory contains performance benchmarks for critical operations in the n8n-mcp project.
|
||||
|
||||
## Running Benchmarks
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
# Run all benchmarks
|
||||
npm run benchmark
|
||||
|
||||
# Watch mode for development
|
||||
npm run benchmark:watch
|
||||
|
||||
# Interactive UI
|
||||
npm run benchmark:ui
|
||||
|
||||
# Run specific benchmark file
|
||||
npx vitest bench tests/benchmarks/node-loading.bench.ts
|
||||
```
|
||||
|
||||
### CI/CD
|
||||
|
||||
Benchmarks run automatically on:
|
||||
- Every push to `main` branch
|
||||
- Every pull request
|
||||
- Manual workflow dispatch
|
||||
|
||||
## Benchmark Suites
|
||||
|
||||
### 1. Node Loading Performance (`node-loading.bench.ts`)
|
||||
- Package loading (n8n-nodes-base, @n8n/n8n-nodes-langchain)
|
||||
- Individual node file loading
|
||||
- Package.json parsing
|
||||
|
||||
### 2. Database Query Performance (`database-queries.bench.ts`)
|
||||
- Node retrieval by type
|
||||
- Category filtering
|
||||
- Search operations (OR, AND, FUZZY modes)
|
||||
- Node counting and statistics
|
||||
- Insert/update operations
|
||||
|
||||
### 3. Search Operations (`search-operations.bench.ts`)
|
||||
- Single and multi-word searches
|
||||
- Exact phrase matching
|
||||
- Fuzzy search performance
|
||||
- Property search within nodes
|
||||
- Complex filtering operations
|
||||
|
||||
### 4. Validation Performance (`validation-performance.bench.ts`)
|
||||
- Node configuration validation (minimal, strict, ai-friendly)
|
||||
- Expression validation
|
||||
- Workflow validation
|
||||
- Property dependency resolution
|
||||
|
||||
### 5. MCP Tool Execution (`mcp-tools.bench.ts`)
|
||||
- Tool execution overhead
|
||||
- Response formatting
|
||||
- Complex query handling
|
||||
|
||||
## Performance Targets
|
||||
|
||||
| Operation | Target | Alert Threshold |
|
||||
|-----------|--------|-----------------|
|
||||
| Node loading | <100ms per package | >150ms |
|
||||
| Database query | <5ms per query | >10ms |
|
||||
| Search (simple) | <10ms | >20ms |
|
||||
| Search (complex) | <50ms | >100ms |
|
||||
| Validation (simple) | <1ms | >2ms |
|
||||
| Validation (complex) | <10ms | >20ms |
|
||||
| MCP tool execution | <50ms | >100ms |
|
||||
|
||||
## Benchmark Results
|
||||
|
||||
- Results are tracked over time using GitHub Actions
|
||||
- Historical data available at: https://czlonkowski.github.io/n8n-mcp/benchmarks/
|
||||
- Performance regressions >10% trigger automatic alerts
|
||||
- PR comments show benchmark comparisons
|
||||
|
||||
## Writing New Benchmarks
|
||||
|
||||
```typescript
|
||||
import { bench, describe } from 'vitest';
|
||||
|
||||
describe('My Performance Suite', () => {
|
||||
bench('operation name', async () => {
|
||||
// Code to benchmark
|
||||
}, {
|
||||
iterations: 100, // Number of times to run
|
||||
warmupIterations: 10, // Warmup runs (not measured)
|
||||
warmupTime: 500, // Warmup duration in ms
|
||||
time: 3000 // Total benchmark duration in ms
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Isolate Operations**: Benchmark specific operations, not entire workflows
|
||||
2. **Use Realistic Data**: Load actual n8n nodes for realistic measurements
|
||||
3. **Warmup**: Always include warmup iterations to avoid JIT compilation effects
|
||||
4. **Memory**: Use in-memory databases for consistent results
|
||||
5. **Iterations**: Balance between accuracy and execution time
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Inconsistent Results
|
||||
- Increase `warmupIterations` and `warmupTime`
|
||||
- Run benchmarks in isolation
|
||||
- Check for background processes
|
||||
|
||||
### Memory Issues
|
||||
- Reduce `iterations` for memory-intensive operations
|
||||
- Add cleanup in `afterEach` hooks
|
||||
- Monitor memory usage during benchmarks
|
||||
|
||||
### CI Failures
|
||||
- Check benchmark timeout settings
|
||||
- Verify GitHub Actions runner resources
|
||||
- Review alert thresholds for false positives
|
||||
@@ -1,160 +0,0 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { NodeRepository } from '../../src/database/node-repository';
|
||||
import { SQLiteStorageService } from '../../src/services/sqlite-storage-service';
|
||||
import { NodeFactory } from '../factories/node-factory';
|
||||
import { PropertyDefinitionFactory } from '../factories/property-definition-factory';
|
||||
|
||||
/**
|
||||
* Database Query Performance Benchmarks
|
||||
*
|
||||
* NOTE: These benchmarks use MOCK DATA (500 artificial test nodes)
|
||||
* created with factories, not the real production database.
|
||||
*
|
||||
* This is useful for tracking database layer performance in isolation,
|
||||
* but may not reflect real-world performance characteristics.
|
||||
*
|
||||
* For end-to-end MCP tool performance with real data, see mcp-tools.bench.ts
|
||||
*/
|
||||
describe('Database Query Performance', () => {
|
||||
let repository: NodeRepository;
|
||||
let storage: SQLiteStorageService;
|
||||
const testNodeCount = 500;
|
||||
|
||||
beforeAll(async () => {
|
||||
storage = new SQLiteStorageService(':memory:');
|
||||
repository = new NodeRepository(storage);
|
||||
|
||||
// Seed database with test data
|
||||
for (let i = 0; i < testNodeCount; i++) {
|
||||
const node = NodeFactory.build({
|
||||
displayName: `TestNode${i}`,
|
||||
nodeType: `nodes-base.testNode${i}`,
|
||||
category: i % 2 === 0 ? 'transform' : 'trigger',
|
||||
packageName: 'n8n-nodes-base',
|
||||
documentation: `Test documentation for node ${i}`,
|
||||
properties: PropertyDefinitionFactory.buildList(5)
|
||||
});
|
||||
await repository.upsertNode(node);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
storage.close();
|
||||
});
|
||||
|
||||
bench('getNodeByType - existing node', async () => {
|
||||
await repository.getNodeByType('nodes-base.testNode100');
|
||||
}, {
|
||||
iterations: 1000,
|
||||
warmupIterations: 100,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('getNodeByType - non-existing node', async () => {
|
||||
await repository.getNodeByType('nodes-base.nonExistentNode');
|
||||
}, {
|
||||
iterations: 1000,
|
||||
warmupIterations: 100,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('getNodesByCategory - transform', async () => {
|
||||
await repository.getNodesByCategory('transform');
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('searchNodes - OR mode', async () => {
|
||||
await repository.searchNodes('test node data', 'OR', 20);
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('searchNodes - AND mode', async () => {
|
||||
await repository.searchNodes('test node', 'AND', 20);
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('searchNodes - FUZZY mode', async () => {
|
||||
await repository.searchNodes('tst nde', 'FUZZY', 20);
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('getAllNodes - no limit', async () => {
|
||||
await repository.getAllNodes();
|
||||
}, {
|
||||
iterations: 50,
|
||||
warmupIterations: 5,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('getAllNodes - with limit', async () => {
|
||||
await repository.getAllNodes(50);
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('getNodeCount', async () => {
|
||||
await repository.getNodeCount();
|
||||
}, {
|
||||
iterations: 1000,
|
||||
warmupIterations: 100,
|
||||
warmupTime: 100,
|
||||
time: 2000
|
||||
});
|
||||
|
||||
bench('getAIToolNodes', async () => {
|
||||
await repository.getAIToolNodes();
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('upsertNode - new node', async () => {
|
||||
const node = NodeFactory.build({
|
||||
displayName: `BenchNode${Date.now()}`,
|
||||
nodeType: `nodes-base.benchNode${Date.now()}`
|
||||
});
|
||||
await repository.upsertNode(node);
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('upsertNode - existing node update', async () => {
|
||||
const existingNode = await repository.getNodeByType('nodes-base.testNode0');
|
||||
if (existingNode) {
|
||||
existingNode.description = `Updated description ${Date.now()}`;
|
||||
await repository.upsertNode(existingNode);
|
||||
}
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
// Export all benchmark suites
|
||||
export * from './database-queries.bench';
|
||||
export * from './mcp-tools.bench';
|
||||
@@ -1,169 +0,0 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { NodeRepository } from '../../src/database/node-repository';
|
||||
import { createDatabaseAdapter } from '../../src/database/database-adapter';
|
||||
import { EnhancedConfigValidator } from '../../src/services/enhanced-config-validator';
|
||||
import { PropertyFilter } from '../../src/services/property-filter';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* MCP Tool Performance Benchmarks
|
||||
*
|
||||
* These benchmarks measure end-to-end performance of actual MCP tool operations
|
||||
* using the REAL production database (data/nodes.db with 525+ nodes).
|
||||
*
|
||||
* Unlike database-queries.bench.ts which uses mock data, these benchmarks
|
||||
* reflect what AI assistants actually experience when calling MCP tools,
|
||||
* making this the most meaningful performance metric for the system.
|
||||
*/
|
||||
describe('MCP Tool Performance (Production Database)', () => {
|
||||
let repository: NodeRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Use REAL production database
|
||||
const dbPath = path.join(__dirname, '../../data/nodes.db');
|
||||
const db = await createDatabaseAdapter(dbPath);
|
||||
repository = new NodeRepository(db);
|
||||
// Initialize similarity services for validation
|
||||
EnhancedConfigValidator.initializeSimilarityServices(repository);
|
||||
});
|
||||
|
||||
/**
|
||||
* search_nodes - Most frequently used tool for node discovery
|
||||
*
|
||||
* This measures:
|
||||
* - Database FTS5 full-text search
|
||||
* - Result filtering and ranking
|
||||
* - Response serialization
|
||||
*
|
||||
* Target: <20ms for common queries
|
||||
*/
|
||||
bench('search_nodes - common query (http)', async () => {
|
||||
await repository.searchNodes('http', 'OR', 20);
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('search_nodes - AI agent query (slack message)', async () => {
|
||||
await repository.searchNodes('slack send message', 'AND', 10);
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
/**
|
||||
* get_node_essentials - Fast retrieval of node configuration
|
||||
*
|
||||
* This measures:
|
||||
* - Database node lookup
|
||||
* - Property filtering (essentials only)
|
||||
* - Response formatting
|
||||
*
|
||||
* Target: <10ms for most nodes
|
||||
*/
|
||||
bench('get_node_essentials - HTTP Request node', async () => {
|
||||
const node = await repository.getNodeByType('n8n-nodes-base.httpRequest');
|
||||
if (node && node.properties) {
|
||||
PropertyFilter.getEssentials(node.properties, node.nodeType);
|
||||
}
|
||||
}, {
|
||||
iterations: 200,
|
||||
warmupIterations: 20,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('get_node_essentials - Slack node', async () => {
|
||||
const node = await repository.getNodeByType('n8n-nodes-base.slack');
|
||||
if (node && node.properties) {
|
||||
PropertyFilter.getEssentials(node.properties, node.nodeType);
|
||||
}
|
||||
}, {
|
||||
iterations: 200,
|
||||
warmupIterations: 20,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
/**
|
||||
* list_nodes - Initial exploration/listing
|
||||
*
|
||||
* This measures:
|
||||
* - Database query with pagination
|
||||
* - Result serialization
|
||||
* - Category filtering
|
||||
*
|
||||
* Target: <15ms for first page
|
||||
*/
|
||||
bench('list_nodes - first 50 nodes', async () => {
|
||||
await repository.getAllNodes(50);
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('list_nodes - AI tools only', async () => {
|
||||
await repository.getAIToolNodes();
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
/**
|
||||
* validate_node_operation - Configuration validation
|
||||
*
|
||||
* This measures:
|
||||
* - Schema lookup
|
||||
* - Validation logic execution
|
||||
* - Error message formatting
|
||||
*
|
||||
* Target: <15ms for simple validations
|
||||
*/
|
||||
bench('validate_node_operation - HTTP Request (minimal)', async () => {
|
||||
const node = await repository.getNodeByType('n8n-nodes-base.httpRequest');
|
||||
if (node && node.properties) {
|
||||
EnhancedConfigValidator.validateWithMode(
|
||||
'n8n-nodes-base.httpRequest',
|
||||
{},
|
||||
node.properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
}
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('validate_node_operation - HTTP Request (with params)', async () => {
|
||||
const node = await repository.getNodeByType('n8n-nodes-base.httpRequest');
|
||||
if (node && node.properties) {
|
||||
EnhancedConfigValidator.validateWithMode(
|
||||
'n8n-nodes-base.httpRequest',
|
||||
{
|
||||
requestMethod: 'GET',
|
||||
url: 'https://api.example.com',
|
||||
authentication: 'none'
|
||||
},
|
||||
node.properties,
|
||||
'operation',
|
||||
'ai-friendly'
|
||||
);
|
||||
}
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { MCPEngine } from '../../src/mcp-tools-engine';
|
||||
import { NodeRepository } from '../../src/database/node-repository';
|
||||
import { SQLiteStorageService } from '../../src/services/sqlite-storage-service';
|
||||
import { N8nNodeLoader } from '../../src/loaders/node-loader';
|
||||
|
||||
describe('MCP Tool Execution Performance', () => {
|
||||
let engine: MCPEngine;
|
||||
let storage: SQLiteStorageService;
|
||||
|
||||
beforeAll(async () => {
|
||||
storage = new SQLiteStorageService(':memory:');
|
||||
const repository = new NodeRepository(storage);
|
||||
const loader = new N8nNodeLoader(repository);
|
||||
await loader.loadPackage('n8n-nodes-base');
|
||||
|
||||
engine = new MCPEngine(repository);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
storage.close();
|
||||
});
|
||||
|
||||
bench('list_nodes - default limit', async () => {
|
||||
await engine.listNodes({});
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('list_nodes - large limit', async () => {
|
||||
await engine.listNodes({ limit: 200 });
|
||||
}, {
|
||||
iterations: 50,
|
||||
warmupIterations: 5,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('list_nodes - filtered by category', async () => {
|
||||
await engine.listNodes({ category: 'transform', limit: 100 });
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('search_nodes - single word', async () => {
|
||||
await engine.searchNodes({ query: 'http' });
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('search_nodes - multiple words', async () => {
|
||||
await engine.searchNodes({ query: 'http request webhook', mode: 'OR' });
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('get_node_info', async () => {
|
||||
await engine.getNodeInfo({ nodeType: 'n8n-nodes-base.httpRequest' });
|
||||
}, {
|
||||
iterations: 500,
|
||||
warmupIterations: 50,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('get_node_essentials', async () => {
|
||||
await engine.getNodeEssentials({ nodeType: 'n8n-nodes-base.httpRequest' });
|
||||
}, {
|
||||
iterations: 1000,
|
||||
warmupIterations: 100,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('get_node_documentation', async () => {
|
||||
await engine.getNodeDocumentation({ nodeType: 'n8n-nodes-base.httpRequest' });
|
||||
}, {
|
||||
iterations: 500,
|
||||
warmupIterations: 50,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('validate_node_operation - simple', async () => {
|
||||
await engine.validateNodeOperation({
|
||||
nodeType: 'n8n-nodes-base.httpRequest',
|
||||
config: {
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET'
|
||||
},
|
||||
profile: 'minimal'
|
||||
});
|
||||
}, {
|
||||
iterations: 1000,
|
||||
warmupIterations: 100,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('validate_node_operation - complex', async () => {
|
||||
await engine.validateNodeOperation({
|
||||
nodeType: 'n8n-nodes-base.slack',
|
||||
config: {
|
||||
resource: 'message',
|
||||
operation: 'send',
|
||||
channel: 'C1234567890',
|
||||
text: 'Hello from benchmark'
|
||||
},
|
||||
profile: 'strict'
|
||||
});
|
||||
}, {
|
||||
iterations: 500,
|
||||
warmupIterations: 50,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('validate_node_minimal', async () => {
|
||||
await engine.validateNodeMinimal({
|
||||
nodeType: 'n8n-nodes-base.httpRequest',
|
||||
config: {}
|
||||
});
|
||||
}, {
|
||||
iterations: 2000,
|
||||
warmupIterations: 200,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('search_node_properties', async () => {
|
||||
await engine.searchNodeProperties({
|
||||
nodeType: 'n8n-nodes-base.httpRequest',
|
||||
query: 'authentication'
|
||||
});
|
||||
}, {
|
||||
iterations: 500,
|
||||
warmupIterations: 50,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('get_node_for_task', async () => {
|
||||
await engine.getNodeForTask({ task: 'post_json_request' });
|
||||
}, {
|
||||
iterations: 1000,
|
||||
warmupIterations: 100,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('list_ai_tools', async () => {
|
||||
await engine.listAITools({});
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('get_database_statistics', async () => {
|
||||
await engine.getDatabaseStatistics({});
|
||||
}, {
|
||||
iterations: 1000,
|
||||
warmupIterations: 100,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('validate_workflow - simple', async () => {
|
||||
await engine.validateWorkflow({
|
||||
workflow: {
|
||||
name: 'Test',
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Manual',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [250, 300],
|
||||
parameters: {}
|
||||
}
|
||||
],
|
||||
connections: {}
|
||||
}
|
||||
});
|
||||
}, {
|
||||
iterations: 500,
|
||||
warmupIterations: 50,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
// This benchmark is temporarily disabled due to API changes in N8nNodeLoader
|
||||
// The benchmark needs to be updated to work with the new loader API
|
||||
@@ -1,59 +0,0 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { N8nNodeLoader } from '../../src/loaders/node-loader';
|
||||
import { NodeRepository } from '../../src/database/node-repository';
|
||||
import { SQLiteStorageService } from '../../src/services/sqlite-storage-service';
|
||||
import path from 'path';
|
||||
|
||||
describe('Node Loading Performance', () => {
|
||||
let loader: N8nNodeLoader;
|
||||
let repository: NodeRepository;
|
||||
let storage: SQLiteStorageService;
|
||||
|
||||
beforeAll(() => {
|
||||
storage = new SQLiteStorageService(':memory:');
|
||||
repository = new NodeRepository(storage);
|
||||
loader = new N8nNodeLoader(repository);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
storage.close();
|
||||
});
|
||||
|
||||
bench('loadPackage - n8n-nodes-base', async () => {
|
||||
await loader.loadPackage('n8n-nodes-base');
|
||||
}, {
|
||||
iterations: 5,
|
||||
warmupIterations: 2,
|
||||
warmupTime: 1000,
|
||||
time: 5000
|
||||
});
|
||||
|
||||
bench('loadPackage - @n8n/n8n-nodes-langchain', async () => {
|
||||
await loader.loadPackage('@n8n/n8n-nodes-langchain');
|
||||
}, {
|
||||
iterations: 5,
|
||||
warmupIterations: 2,
|
||||
warmupTime: 1000,
|
||||
time: 5000
|
||||
});
|
||||
|
||||
bench('loadNodesFromPath - single file', async () => {
|
||||
const testPath = path.join(process.cwd(), 'node_modules/n8n-nodes-base/dist/nodes/HttpRequest');
|
||||
await loader.loadNodesFromPath(testPath, 'n8n-nodes-base');
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('parsePackageJson', async () => {
|
||||
const packageJsonPath = path.join(process.cwd(), 'node_modules/n8n-nodes-base/package.json');
|
||||
await loader['parsePackageJson'](packageJsonPath);
|
||||
}, {
|
||||
iterations: 1000,
|
||||
warmupIterations: 100,
|
||||
warmupTime: 100,
|
||||
time: 2000
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { NodeRepository } from '../../src/database/node-repository';
|
||||
import { SQLiteStorageService } from '../../src/services/sqlite-storage-service';
|
||||
import { N8nNodeLoader } from '../../src/loaders/node-loader';
|
||||
|
||||
describe('Search Operations Performance', () => {
|
||||
let repository: NodeRepository;
|
||||
let storage: SQLiteStorageService;
|
||||
|
||||
beforeAll(async () => {
|
||||
storage = new SQLiteStorageService(':memory:');
|
||||
repository = new NodeRepository(storage);
|
||||
const loader = new N8nNodeLoader(repository);
|
||||
|
||||
// Load real nodes for realistic benchmarking
|
||||
await loader.loadPackage('n8n-nodes-base');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
storage.close();
|
||||
});
|
||||
|
||||
bench('searchNodes - single word', async () => {
|
||||
await repository.searchNodes('http', 'OR', 20);
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('searchNodes - multiple words OR', async () => {
|
||||
await repository.searchNodes('http request webhook', 'OR', 20);
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('searchNodes - multiple words AND', async () => {
|
||||
await repository.searchNodes('http request', 'AND', 20);
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('searchNodes - fuzzy search', async () => {
|
||||
await repository.searchNodes('htpp requst', 'FUZZY', 20);
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('searchNodes - exact phrase', async () => {
|
||||
await repository.searchNodes('"HTTP Request"', 'OR', 20);
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('searchNodes - large result set', async () => {
|
||||
await repository.searchNodes('data', 'OR', 100);
|
||||
}, {
|
||||
iterations: 50,
|
||||
warmupIterations: 5,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('searchNodes - no results', async () => {
|
||||
await repository.searchNodes('xyznonexistentquery123', 'OR', 20);
|
||||
}, {
|
||||
iterations: 200,
|
||||
warmupIterations: 20,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('searchNodeProperties - common property', async () => {
|
||||
const node = await repository.getNodeByType('n8n-nodes-base.httpRequest');
|
||||
if (node) {
|
||||
await repository.searchNodeProperties(node.type, 'url', 20);
|
||||
}
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('searchNodeProperties - nested property', async () => {
|
||||
const node = await repository.getNodeByType('n8n-nodes-base.httpRequest');
|
||||
if (node) {
|
||||
await repository.searchNodeProperties(node.type, 'authentication', 20);
|
||||
}
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('getNodesByCategory - all categories', async () => {
|
||||
const categories = ['trigger', 'transform', 'output', 'input'];
|
||||
for (const category of categories) {
|
||||
await repository.getNodesByCategory(category);
|
||||
}
|
||||
}, {
|
||||
iterations: 50,
|
||||
warmupIterations: 5,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('getNodesByPackage', async () => {
|
||||
await repository.getNodesByPackage('n8n-nodes-base');
|
||||
}, {
|
||||
iterations: 50,
|
||||
warmupIterations: 5,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('complex filter - AI tools in transform category', async () => {
|
||||
const allNodes = await repository.getAllNodes();
|
||||
const filtered = allNodes.filter(node =>
|
||||
node.category === 'transform' &&
|
||||
node.isAITool
|
||||
);
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
});
|
||||
@@ -1,181 +0,0 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { ConfigValidator } from '../../src/services/config-validator';
|
||||
import { EnhancedConfigValidator } from '../../src/services/enhanced-config-validator';
|
||||
import { ExpressionValidator } from '../../src/services/expression-validator';
|
||||
import { WorkflowValidator } from '../../src/services/workflow-validator';
|
||||
import { NodeRepository } from '../../src/database/node-repository';
|
||||
import { SQLiteStorageService } from '../../src/services/sqlite-storage-service';
|
||||
import { N8nNodeLoader } from '../../src/loaders/node-loader';
|
||||
|
||||
describe('Validation Performance', () => {
|
||||
let workflowValidator: WorkflowValidator;
|
||||
let repository: NodeRepository;
|
||||
let storage: SQLiteStorageService;
|
||||
|
||||
const simpleConfig = {
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET',
|
||||
authentication: 'none'
|
||||
};
|
||||
|
||||
const complexConfig = {
|
||||
resource: 'message',
|
||||
operation: 'send',
|
||||
channel: 'C1234567890',
|
||||
text: 'Hello from benchmark',
|
||||
authentication: {
|
||||
type: 'oAuth2',
|
||||
credentials: {
|
||||
oauthTokenData: {
|
||||
access_token: 'xoxb-test-token'
|
||||
}
|
||||
}
|
||||
},
|
||||
options: {
|
||||
as_user: true,
|
||||
link_names: true,
|
||||
parse: 'full',
|
||||
reply_broadcast: false,
|
||||
thread_ts: '',
|
||||
unfurl_links: true,
|
||||
unfurl_media: true
|
||||
}
|
||||
};
|
||||
|
||||
const simpleWorkflow = {
|
||||
name: 'Simple Workflow',
|
||||
nodes: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'HTTP Request',
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 4.2,
|
||||
position: [450, 300] as [number, number],
|
||||
parameters: {
|
||||
url: 'https://api.example.com',
|
||||
method: 'GET'
|
||||
}
|
||||
}
|
||||
],
|
||||
connections: {
|
||||
'1': {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
node: '2',
|
||||
type: 'main',
|
||||
index: 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const complexWorkflow = {
|
||||
name: 'Complex Workflow',
|
||||
nodes: Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `${i + 1}`,
|
||||
name: `Node ${i + 1}`,
|
||||
type: i % 3 === 0 ? 'n8n-nodes-base.httpRequest' :
|
||||
i % 3 === 1 ? 'n8n-nodes-base.slack' :
|
||||
'n8n-nodes-base.code',
|
||||
typeVersion: 1,
|
||||
position: [250 + (i % 5) * 200, 300 + Math.floor(i / 5) * 150] as [number, number],
|
||||
parameters: {
|
||||
url: '={{ $json.url }}',
|
||||
method: 'POST',
|
||||
body: '={{ JSON.stringify($json) }}',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
})),
|
||||
connections: Object.fromEntries(
|
||||
Array.from({ length: 19 }, (_, i) => [
|
||||
`${i + 1}`,
|
||||
{
|
||||
main: [[{ node: `${i + 2}`, type: 'main', index: 0 }]]
|
||||
}
|
||||
])
|
||||
)
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
storage = new SQLiteStorageService(':memory:');
|
||||
repository = new NodeRepository(storage);
|
||||
const loader = new N8nNodeLoader(repository);
|
||||
await loader.loadPackage('n8n-nodes-base');
|
||||
|
||||
workflowValidator = new WorkflowValidator(repository);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
storage.close();
|
||||
});
|
||||
|
||||
// Note: ConfigValidator and EnhancedConfigValidator have static methods,
|
||||
// so instance-based benchmarks are not applicable
|
||||
|
||||
bench('validateExpression - simple expression', async () => {
|
||||
ExpressionValidator.validateExpression('{{ $json.data }}');
|
||||
}, {
|
||||
iterations: 5000,
|
||||
warmupIterations: 500,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('validateExpression - complex expression', async () => {
|
||||
ExpressionValidator.validateExpression('{{ $node["HTTP Request"].json.items.map(item => item.id).join(",") }}');
|
||||
}, {
|
||||
iterations: 2000,
|
||||
warmupIterations: 200,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('validateWorkflow - simple workflow', async () => {
|
||||
await workflowValidator.validateWorkflow(simpleWorkflow);
|
||||
}, {
|
||||
iterations: 500,
|
||||
warmupIterations: 50,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('validateWorkflow - complex workflow', async () => {
|
||||
await workflowValidator.validateWorkflow(complexWorkflow);
|
||||
}, {
|
||||
iterations: 100,
|
||||
warmupIterations: 10,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('validateWorkflow - connections only', async () => {
|
||||
await workflowValidator.validateConnections(simpleWorkflow);
|
||||
}, {
|
||||
iterations: 1000,
|
||||
warmupIterations: 100,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
|
||||
bench('validateWorkflow - expressions only', async () => {
|
||||
await workflowValidator.validateExpressions(complexWorkflow);
|
||||
}, {
|
||||
iterations: 500,
|
||||
warmupIterations: 50,
|
||||
warmupTime: 500,
|
||||
time: 3000
|
||||
});
|
||||
});
|
||||
@@ -1,267 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
createTestDatabase,
|
||||
seedTestNodes,
|
||||
seedTestTemplates,
|
||||
createTestNode,
|
||||
createTestTemplate,
|
||||
createDatabaseSnapshot,
|
||||
restoreDatabaseSnapshot,
|
||||
loadFixtures,
|
||||
dbHelpers,
|
||||
TestDatabase
|
||||
} from '../utils/database-utils';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* Example test file showing how to use database utilities
|
||||
* in real test scenarios
|
||||
*/
|
||||
|
||||
describe('Example: Using Database Utils in Tests', () => {
|
||||
let testDb: TestDatabase;
|
||||
|
||||
// Always cleanup after each test
|
||||
afterEach(async () => {
|
||||
if (testDb) {
|
||||
await testDb.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
describe('Basic Database Setup', () => {
|
||||
it('should setup a test database for unit testing', async () => {
|
||||
// Create an in-memory database for fast tests
|
||||
testDb = await createTestDatabase();
|
||||
|
||||
// Seed some test data
|
||||
await seedTestNodes(testDb.nodeRepository, [
|
||||
{ nodeType: 'nodes-base.myCustomNode', displayName: 'My Custom Node' }
|
||||
]);
|
||||
|
||||
// Use the repository to test your logic
|
||||
const node = testDb.nodeRepository.getNode('nodes-base.myCustomNode');
|
||||
expect(node).toBeDefined();
|
||||
expect(node.displayName).toBe('My Custom Node');
|
||||
});
|
||||
|
||||
it('should setup a file-based database for integration testing', async () => {
|
||||
// Create a file-based database when you need persistence
|
||||
testDb = await createTestDatabase({
|
||||
inMemory: false,
|
||||
dbPath: path.join(__dirname, '../temp/integration-test.db')
|
||||
});
|
||||
|
||||
// The database will persist until cleanup() is called
|
||||
await seedTestNodes(testDb.nodeRepository);
|
||||
|
||||
// You can verify the file exists
|
||||
expect(testDb.path).toContain('integration-test.db');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Testing with Fixtures', () => {
|
||||
it('should load complex test scenarios from fixtures', async () => {
|
||||
testDb = await createTestDatabase();
|
||||
|
||||
// Load fixtures from JSON file
|
||||
const fixturePath = path.join(__dirname, '../fixtures/database/test-nodes.json');
|
||||
await loadFixtures(testDb.adapter, fixturePath);
|
||||
|
||||
// Verify the fixture data was loaded
|
||||
expect(dbHelpers.countRows(testDb.adapter, 'nodes')).toBe(3);
|
||||
expect(dbHelpers.countRows(testDb.adapter, 'templates')).toBe(1);
|
||||
|
||||
// Test your business logic with the fixture data
|
||||
const slackNode = testDb.nodeRepository.getNode('nodes-base.slack');
|
||||
expect(slackNode.isAITool).toBe(true);
|
||||
expect(slackNode.category).toBe('Communication');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Testing Repository Methods', () => {
|
||||
beforeEach(async () => {
|
||||
testDb = await createTestDatabase();
|
||||
});
|
||||
|
||||
it('should test custom repository queries', async () => {
|
||||
// Seed nodes with specific properties
|
||||
await seedTestNodes(testDb.nodeRepository, [
|
||||
{ nodeType: 'nodes-base.ai1', isAITool: true },
|
||||
{ nodeType: 'nodes-base.ai2', isAITool: true },
|
||||
{ nodeType: 'nodes-base.regular', isAITool: false }
|
||||
]);
|
||||
|
||||
// Test custom queries
|
||||
const aiNodes = testDb.nodeRepository.getAITools();
|
||||
expect(aiNodes).toHaveLength(4); // 2 custom + 2 default (httpRequest, slack)
|
||||
|
||||
// Use dbHelpers for quick checks
|
||||
const allNodeTypes = dbHelpers.getAllNodeTypes(testDb.adapter);
|
||||
expect(allNodeTypes).toContain('nodes-base.ai1');
|
||||
expect(allNodeTypes).toContain('nodes-base.ai2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Testing with Snapshots', () => {
|
||||
it('should test rollback scenarios using snapshots', async () => {
|
||||
testDb = await createTestDatabase();
|
||||
|
||||
// Setup initial state
|
||||
await seedTestNodes(testDb.nodeRepository);
|
||||
await seedTestTemplates(testDb.templateRepository);
|
||||
|
||||
// Create a snapshot of the good state
|
||||
const snapshot = await createDatabaseSnapshot(testDb.adapter);
|
||||
|
||||
// Perform operations that might fail
|
||||
try {
|
||||
// Simulate a complex operation
|
||||
await testDb.nodeRepository.saveNode(createTestNode({
|
||||
nodeType: 'nodes-base.problematic',
|
||||
displayName: 'This might cause issues'
|
||||
}));
|
||||
|
||||
// Simulate an error
|
||||
throw new Error('Something went wrong!');
|
||||
} catch (error) {
|
||||
// Restore to the known good state
|
||||
await restoreDatabaseSnapshot(testDb.adapter, snapshot);
|
||||
}
|
||||
|
||||
// Verify we're back to the original state
|
||||
expect(dbHelpers.countRows(testDb.adapter, 'nodes')).toBe(snapshot.metadata.nodeCount);
|
||||
expect(dbHelpers.nodeExists(testDb.adapter, 'nodes-base.problematic')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Testing Database Performance', () => {
|
||||
it('should measure performance of database operations', async () => {
|
||||
testDb = await createTestDatabase();
|
||||
|
||||
// Measure bulk insert performance
|
||||
const insertDuration = await measureDatabaseOperation('Bulk Insert', async () => {
|
||||
const nodes = Array.from({ length: 100 }, (_, i) =>
|
||||
createTestNode({
|
||||
nodeType: `nodes-base.perf${i}`,
|
||||
displayName: `Performance Test Node ${i}`
|
||||
})
|
||||
);
|
||||
|
||||
for (const node of nodes) {
|
||||
testDb.nodeRepository.saveNode(node);
|
||||
}
|
||||
});
|
||||
|
||||
// Measure query performance
|
||||
const queryDuration = await measureDatabaseOperation('Query All Nodes', async () => {
|
||||
const allNodes = testDb.nodeRepository.getAllNodes();
|
||||
expect(allNodes.length).toBe(100); // 100 bulk nodes (no defaults as we're not using seedTestNodes)
|
||||
});
|
||||
|
||||
// Assert reasonable performance
|
||||
expect(insertDuration).toBeLessThan(1000); // Should complete in under 1 second
|
||||
expect(queryDuration).toBeLessThan(100); // Queries should be fast
|
||||
});
|
||||
});
|
||||
|
||||
describe('Testing with Different Database States', () => {
|
||||
it('should test behavior with empty database', async () => {
|
||||
testDb = await createTestDatabase();
|
||||
|
||||
// Test with empty database
|
||||
expect(dbHelpers.countRows(testDb.adapter, 'nodes')).toBe(0);
|
||||
|
||||
const nonExistentNode = testDb.nodeRepository.getNode('nodes-base.doesnotexist');
|
||||
expect(nonExistentNode).toBeNull();
|
||||
});
|
||||
|
||||
it('should test behavior with populated database', async () => {
|
||||
testDb = await createTestDatabase();
|
||||
|
||||
// Populate with many nodes
|
||||
const nodes = Array.from({ length: 50 }, (_, i) => ({
|
||||
nodeType: `nodes-base.node${i}`,
|
||||
displayName: `Node ${i}`,
|
||||
category: i % 2 === 0 ? 'Category A' : 'Category B'
|
||||
}));
|
||||
|
||||
await seedTestNodes(testDb.nodeRepository, nodes);
|
||||
|
||||
// Test queries on populated database
|
||||
const allNodes = dbHelpers.getAllNodeTypes(testDb.adapter);
|
||||
expect(allNodes.length).toBe(53); // 50 custom + 3 default
|
||||
|
||||
// Test filtering by category
|
||||
const categoryANodes = testDb.adapter
|
||||
.prepare('SELECT COUNT(*) as count FROM nodes WHERE category = ?')
|
||||
.get('Category A') as { count: number };
|
||||
|
||||
expect(categoryANodes.count).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Testing Error Scenarios', () => {
|
||||
it('should handle database errors gracefully', async () => {
|
||||
testDb = await createTestDatabase();
|
||||
|
||||
// Test saving invalid data
|
||||
const invalidNode = createTestNode({
|
||||
nodeType: '', // Invalid: empty nodeType
|
||||
displayName: 'Invalid Node'
|
||||
});
|
||||
|
||||
// SQLite allows NULL in PRIMARY KEY, so test with empty string instead
|
||||
// which should violate any business logic constraints
|
||||
// For now, we'll just verify the save doesn't crash
|
||||
expect(() => {
|
||||
testDb.nodeRepository.saveNode(invalidNode);
|
||||
}).not.toThrow();
|
||||
|
||||
// Database should still be functional
|
||||
await seedTestNodes(testDb.nodeRepository);
|
||||
expect(dbHelpers.countRows(testDb.adapter, 'nodes')).toBe(4); // 3 default nodes + 1 invalid node
|
||||
});
|
||||
});
|
||||
|
||||
describe('Testing with Transactions', () => {
|
||||
it('should test transactional behavior', async () => {
|
||||
testDb = await createTestDatabase();
|
||||
|
||||
// Seed initial data
|
||||
await seedTestNodes(testDb.nodeRepository);
|
||||
const initialCount = dbHelpers.countRows(testDb.adapter, 'nodes');
|
||||
|
||||
// Use transaction for atomic operations
|
||||
try {
|
||||
testDb.adapter.transaction(() => {
|
||||
// Add multiple nodes atomically
|
||||
testDb.nodeRepository.saveNode(createTestNode({ nodeType: 'nodes-base.tx1' }));
|
||||
testDb.nodeRepository.saveNode(createTestNode({ nodeType: 'nodes-base.tx2' }));
|
||||
|
||||
// Simulate error in transaction
|
||||
throw new Error('Transaction failed');
|
||||
});
|
||||
} catch (error) {
|
||||
// Transaction should have rolled back
|
||||
}
|
||||
|
||||
// Verify no nodes were added
|
||||
const finalCount = dbHelpers.countRows(testDb.adapter, 'nodes');
|
||||
expect(finalCount).toBe(initialCount);
|
||||
expect(dbHelpers.nodeExists(testDb.adapter, 'nodes-base.tx1')).toBe(false);
|
||||
expect(dbHelpers.nodeExists(testDb.adapter, 'nodes-base.tx2')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function for performance measurement
|
||||
async function measureDatabaseOperation(
|
||||
name: string,
|
||||
operation: () => Promise<void>
|
||||
): Promise<number> {
|
||||
const start = performance.now();
|
||||
await operation();
|
||||
const duration = performance.now() - start;
|
||||
console.log(`[Performance] ${name}: ${duration.toFixed(2)}ms`);
|
||||
return duration;
|
||||
}
|
||||
@@ -43,7 +43,14 @@ describe.skipIf(!dbExists)('Database Content Validation', () => {
|
||||
// Ignore NODE_DB_PATH env var which might be set to :memory: by vitest
|
||||
db = await createDatabaseAdapter(dbPath);
|
||||
repository = new NodeRepository(db);
|
||||
console.log('✅ Database found - running validation tests');
|
||||
|
||||
// Rebuild FTS5 index to ensure it is in sync with the nodes table.
|
||||
// The content-synced FTS5 index (content=nodes) can become stale if the
|
||||
// database was rebuilt without an explicit FTS5 rebuild command, leaving
|
||||
// phantom rowid references that cause "missing row" errors on MATCH queries.
|
||||
db.prepare("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')").run();
|
||||
|
||||
console.log('Database found - running validation tests');
|
||||
});
|
||||
|
||||
describe('[CRITICAL] Database Must Have Data', () => {
|
||||
|
||||
@@ -87,7 +87,7 @@ class InMemoryDatabaseAdapter implements DatabaseAdapter {
|
||||
|
||||
class InMemoryPreparedStatement implements PreparedStatement {
|
||||
run = vi.fn((...params: any[]): RunResult => {
|
||||
if (this.sql.includes('INSERT OR REPLACE INTO nodes')) {
|
||||
if (this.sql.includes('INSERT') && this.sql.includes('INTO nodes')) {
|
||||
const node = this.paramsToNode(params);
|
||||
this.adapter.saveNode(node);
|
||||
return { changes: 1, lastInsertRowid: 1 };
|
||||
@@ -100,6 +100,9 @@ class InMemoryPreparedStatement implements PreparedStatement {
|
||||
});
|
||||
|
||||
get = vi.fn((...params: any[]) => {
|
||||
if (this.sql.includes('SELECT npm_readme')) {
|
||||
return undefined; // No existing docs to preserve
|
||||
}
|
||||
if (this.sql.includes('SELECT * FROM nodes WHERE node_type = ?')) {
|
||||
return this.adapter.getNode(params[0]);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ describe('Node FTS5 Search Integration Tests', () => {
|
||||
const testDbPath = './data/nodes.db';
|
||||
db = await createDatabaseAdapter(testDbPath);
|
||||
repository = new NodeRepository(db);
|
||||
|
||||
// Rebuild FTS5 index to ensure it is in sync with the nodes table.
|
||||
// The content-synced FTS5 index (content=nodes) can become stale if the
|
||||
// database was rebuilt without an explicit FTS5 rebuild command, leaving
|
||||
// phantom rowid references that cause "missing row" errors on MATCH queries.
|
||||
db.prepare("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')").run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
115
tests/integration/n8n-api/scripts/cleanup-non-test-workflows.ts
Normal file
115
tests/integration/n8n-api/scripts/cleanup-non-test-workflows.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Cleanup Non-Test Workflows
|
||||
*
|
||||
* Deletes all workflows from the n8n test instance EXCEPT those
|
||||
* with "[TEST]" in the name. This helps keep the test instance
|
||||
* clean and prevents list endpoint pagination issues.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx tests/integration/n8n-api/scripts/cleanup-non-test-workflows.ts
|
||||
* npx tsx tests/integration/n8n-api/scripts/cleanup-non-test-workflows.ts --dry-run
|
||||
*/
|
||||
|
||||
import { getN8nCredentials, validateCredentials } from '../utils/credentials';
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
|
||||
interface Workflow {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
async function fetchAllWorkflows(baseUrl: string, apiKey: string): Promise<Workflow[]> {
|
||||
const all: Workflow[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
while (true) {
|
||||
const url = new URL('/api/v1/workflows', baseUrl);
|
||||
url.searchParams.set('limit', '100');
|
||||
if (cursor) url.searchParams.set('cursor', cursor);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { 'X-N8N-API-KEY': apiKey }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to list workflows: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const body = await res.json() as { data: Workflow[]; nextCursor?: string };
|
||||
all.push(...body.data);
|
||||
|
||||
if (!body.nextCursor) break;
|
||||
cursor = body.nextCursor;
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
async function deleteWorkflow(baseUrl: string, apiKey: string, id: string): Promise<void> {
|
||||
const res = await fetch(`${baseUrl}/api/v1/workflows/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-N8N-API-KEY': apiKey }
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to delete workflow ${id}: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const creds = getN8nCredentials();
|
||||
validateCredentials(creds);
|
||||
|
||||
console.log(`n8n Instance: ${creds.url}`);
|
||||
console.log(`Mode: ${DRY_RUN ? 'DRY RUN' : 'LIVE DELETE'}\n`);
|
||||
|
||||
const workflows = await fetchAllWorkflows(creds.url, creds.apiKey);
|
||||
console.log(`Total workflows found: ${workflows.length}\n`);
|
||||
|
||||
const toKeep = workflows.filter(w => w.name.includes('[TEST]'));
|
||||
const toDelete = workflows.filter(w => !w.name.includes('[TEST]'));
|
||||
|
||||
console.log(`Keeping (${toKeep.length}):`);
|
||||
for (const w of toKeep) {
|
||||
console.log(` ✅ ${w.id} - ${w.name}`);
|
||||
}
|
||||
|
||||
console.log(`\nDeleting (${toDelete.length}):`);
|
||||
for (const w of toDelete) {
|
||||
console.log(` 🗑️ ${w.id} - ${w.name}${w.active ? ' (ACTIVE)' : ''}`);
|
||||
}
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log('\nDry run complete. No workflows were deleted.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (toDelete.length === 0) {
|
||||
console.log('\nNothing to delete.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\nDeleting ${toDelete.length} workflows...`);
|
||||
let deleted = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const w of toDelete) {
|
||||
try {
|
||||
await deleteWorkflow(creds.url, creds.apiKey, w.id);
|
||||
deleted++;
|
||||
} catch (err) {
|
||||
console.error(` Failed to delete ${w.id} (${w.name}): ${err}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone! Deleted: ${deleted}, Failed: ${failed}, Kept: ${toKeep.length}`);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,147 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* Integration tests for rate limiting
|
||||
*
|
||||
* SECURITY: These tests verify rate limiting prevents brute force attacks
|
||||
* See: https://github.com/czlonkowski/n8n-mcp/issues/265 (HIGH-02)
|
||||
*
|
||||
* TODO: Re-enable when CI server startup issue is resolved
|
||||
* Server process fails to start on port 3001 in CI with ECONNREFUSED errors
|
||||
* Tests pass locally but consistently fail in GitHub Actions CI environment
|
||||
* Rate limiting functionality is verified and working in production
|
||||
*/
|
||||
describe.skip('Integration: Rate Limiting', () => {
|
||||
let serverProcess: ChildProcess;
|
||||
const port = 3001;
|
||||
const authToken = 'test-token-for-rate-limiting-test-32-chars';
|
||||
|
||||
beforeAll(async () => {
|
||||
// Start HTTP server with rate limiting
|
||||
serverProcess = spawn('node', ['dist/http-server-single-session.js'], {
|
||||
env: {
|
||||
...process.env,
|
||||
MCP_MODE: 'http',
|
||||
PORT: port.toString(),
|
||||
AUTH_TOKEN: authToken,
|
||||
NODE_ENV: 'test',
|
||||
AUTH_RATE_LIMIT_WINDOW: '900000', // 15 minutes
|
||||
AUTH_RATE_LIMIT_MAX: '20', // 20 attempts
|
||||
},
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
// Wait for server to start (longer wait for CI)
|
||||
await new Promise(resolve => setTimeout(resolve, 8000));
|
||||
}, 20000);
|
||||
|
||||
afterAll(() => {
|
||||
if (serverProcess) {
|
||||
serverProcess.kill();
|
||||
}
|
||||
});
|
||||
|
||||
it('should block after max authentication attempts (sequential requests)', async () => {
|
||||
const baseUrl = `http://localhost:${port}/mcp`;
|
||||
|
||||
// IMPORTANT: Use sequential requests to ensure deterministic order
|
||||
// Parallel requests can cause race conditions with in-memory rate limiter
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
const response = await axios.post(
|
||||
baseUrl,
|
||||
{ jsonrpc: '2.0', method: 'initialize', id: i },
|
||||
{
|
||||
headers: { Authorization: 'Bearer wrong-token' },
|
||||
validateStatus: () => true, // Don't throw on error status
|
||||
}
|
||||
);
|
||||
|
||||
if (i <= 20) {
|
||||
// First 20 attempts should be 401 (invalid authentication)
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.data.error.message).toContain('Unauthorized');
|
||||
} else {
|
||||
// Attempts 21+ should be 429 (rate limited)
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.data.error.message).toContain('Too many');
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it('should include rate limit headers', async () => {
|
||||
const baseUrl = `http://localhost:${port}/mcp`;
|
||||
|
||||
const response = await axios.post(
|
||||
baseUrl,
|
||||
{ jsonrpc: '2.0', method: 'initialize', id: 1 },
|
||||
{
|
||||
headers: { Authorization: 'Bearer wrong-token' },
|
||||
validateStatus: () => true,
|
||||
}
|
||||
);
|
||||
|
||||
// Check for standard rate limit headers
|
||||
expect(response.headers['ratelimit-limit']).toBeDefined();
|
||||
expect(response.headers['ratelimit-remaining']).toBeDefined();
|
||||
expect(response.headers['ratelimit-reset']).toBeDefined();
|
||||
}, 15000);
|
||||
|
||||
it('should accept valid tokens within rate limit', async () => {
|
||||
const baseUrl = `http://localhost:${port}/mcp`;
|
||||
|
||||
const response = await axios.post(
|
||||
baseUrl,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test', version: '1.0' },
|
||||
},
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data.result).toBeDefined();
|
||||
}, 15000);
|
||||
|
||||
it('should return JSON-RPC formatted error on rate limit', async () => {
|
||||
const baseUrl = `http://localhost:${port}/mcp`;
|
||||
|
||||
// Exhaust rate limit
|
||||
for (let i = 0; i < 21; i++) {
|
||||
await axios.post(
|
||||
baseUrl,
|
||||
{ jsonrpc: '2.0', method: 'initialize', id: i },
|
||||
{
|
||||
headers: { Authorization: 'Bearer wrong-token' },
|
||||
validateStatus: () => true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get rate limited response
|
||||
const response = await axios.post(
|
||||
baseUrl,
|
||||
{ jsonrpc: '2.0', method: 'initialize', id: 999 },
|
||||
{
|
||||
headers: { Authorization: 'Bearer wrong-token' },
|
||||
validateStatus: () => true,
|
||||
}
|
||||
);
|
||||
|
||||
// Verify JSON-RPC error format
|
||||
expect(response.data).toHaveProperty('jsonrpc', '2.0');
|
||||
expect(response.data).toHaveProperty('error');
|
||||
expect(response.data.error).toHaveProperty('code', -32000);
|
||||
expect(response.data.error).toHaveProperty('message');
|
||||
expect(response.data).toHaveProperty('id', null);
|
||||
}, 60000);
|
||||
});
|
||||
@@ -1,754 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { N8NDocumentationMCPServer } from '../../../src/mcp/server';
|
||||
import { telemetry } from '../../../src/telemetry/telemetry-manager';
|
||||
import { TelemetryConfigManager } from '../../../src/telemetry/config-manager';
|
||||
import { CallToolRequest, ListToolsRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../../src/utils/logger', () => ({
|
||||
Logger: vi.fn().mockImplementation(() => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/telemetry/telemetry-manager', () => ({
|
||||
telemetry: {
|
||||
trackSessionStart: vi.fn(),
|
||||
trackToolUsage: vi.fn(),
|
||||
trackToolSequence: vi.fn(),
|
||||
trackError: vi.fn(),
|
||||
trackSearchQuery: vi.fn(),
|
||||
trackValidationDetails: vi.fn(),
|
||||
trackWorkflowCreation: vi.fn(),
|
||||
trackPerformanceMetric: vi.fn(),
|
||||
getMetrics: vi.fn().mockReturnValue({
|
||||
status: 'enabled',
|
||||
initialized: true,
|
||||
tracking: { eventQueueSize: 0 },
|
||||
processing: { eventsTracked: 0 },
|
||||
errors: { totalErrors: 0 }
|
||||
})
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/telemetry/config-manager');
|
||||
|
||||
// Mock database and other dependencies
|
||||
vi.mock('../../../src/database/node-repository');
|
||||
vi.mock('../../../src/services/enhanced-config-validator');
|
||||
vi.mock('../../../src/services/expression-validator');
|
||||
vi.mock('../../../src/services/workflow-validator');
|
||||
|
||||
// TODO: This test needs to be refactored. It's currently mocking everything
|
||||
// which defeats the purpose of an integration test. It should either:
|
||||
// 1. Be moved to unit tests if we want to test with mocks
|
||||
// 2. Be rewritten as a proper integration test without mocks
|
||||
// Skipping for now to unblock CI - the telemetry functionality is tested
|
||||
// properly in the unit tests at tests/unit/telemetry/
|
||||
describe.skip('MCP Telemetry Integration', () => {
|
||||
let mcpServer: N8NDocumentationMCPServer;
|
||||
let mockTelemetryConfig: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock TelemetryConfigManager
|
||||
mockTelemetryConfig = {
|
||||
isEnabled: vi.fn().mockReturnValue(true),
|
||||
getUserId: vi.fn().mockReturnValue('test-user-123'),
|
||||
disable: vi.fn(),
|
||||
enable: vi.fn(),
|
||||
getStatus: vi.fn().mockReturnValue('enabled')
|
||||
};
|
||||
vi.mocked(TelemetryConfigManager.getInstance).mockReturnValue(mockTelemetryConfig);
|
||||
|
||||
// Mock database repository
|
||||
const mockNodeRepository = {
|
||||
searchNodes: vi.fn().mockResolvedValue({ results: [], totalResults: 0 }),
|
||||
getNodeInfo: vi.fn().mockResolvedValue(null),
|
||||
getAllNodes: vi.fn().mockResolvedValue([]),
|
||||
close: vi.fn()
|
||||
};
|
||||
vi.doMock('../../../src/database/node-repository', () => ({
|
||||
NodeRepository: vi.fn().mockImplementation(() => mockNodeRepository)
|
||||
}));
|
||||
|
||||
// Create a mock server instance to avoid initialization issues
|
||||
const mockServer = {
|
||||
requestHandlers: new Map(),
|
||||
notificationHandlers: new Map(),
|
||||
setRequestHandler: vi.fn((method: string, handler: any) => {
|
||||
mockServer.requestHandlers.set(method, handler);
|
||||
}),
|
||||
setNotificationHandler: vi.fn((method: string, handler: any) => {
|
||||
mockServer.notificationHandlers.set(method, handler);
|
||||
})
|
||||
};
|
||||
|
||||
// Set up basic handlers
|
||||
mockServer.requestHandlers.set('initialize', async () => {
|
||||
telemetry.trackSessionStart();
|
||||
return { protocolVersion: '2024-11-05' };
|
||||
});
|
||||
|
||||
mockServer.requestHandlers.set('tools/call', async (params: any) => {
|
||||
// Use the actual tool name from the request
|
||||
const toolName = params?.name || 'unknown-tool';
|
||||
|
||||
try {
|
||||
// Call executeTool if it's been mocked
|
||||
if ((mcpServer as any).executeTool) {
|
||||
const result = await (mcpServer as any).executeTool(params);
|
||||
|
||||
// Track specific telemetry based on tool type
|
||||
if (toolName === 'search_nodes') {
|
||||
const query = params?.arguments?.query || '';
|
||||
const totalResults = result?.totalResults || 0;
|
||||
const mode = params?.arguments?.mode || 'OR';
|
||||
telemetry.trackSearchQuery(query, totalResults, mode);
|
||||
} else if (toolName === 'validate_workflow') {
|
||||
const workflow = params?.arguments?.workflow || {};
|
||||
const validationPassed = result?.isValid !== false;
|
||||
telemetry.trackWorkflowCreation(workflow, validationPassed);
|
||||
if (!validationPassed && result?.errors) {
|
||||
result.errors.forEach((error: any) => {
|
||||
telemetry.trackValidationDetails(error.nodeType || 'unknown', error.type || 'validation_error', error);
|
||||
});
|
||||
}
|
||||
} else if (toolName === 'validate_node_operation' || toolName === 'validate_node_minimal') {
|
||||
const nodeType = params?.arguments?.nodeType || 'unknown';
|
||||
const errorType = result?.errors?.[0]?.type || 'validation_error';
|
||||
telemetry.trackValidationDetails(nodeType, errorType, result);
|
||||
}
|
||||
|
||||
// Simulate a duration for tool execution
|
||||
const duration = params?.duration || Math.random() * 100;
|
||||
telemetry.trackToolUsage(toolName, true, duration);
|
||||
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
||||
} else {
|
||||
// Default behavior if executeTool is not mocked
|
||||
telemetry.trackToolUsage(toolName, true);
|
||||
return { content: [{ type: 'text', text: 'Success' }] };
|
||||
}
|
||||
} catch (error: any) {
|
||||
telemetry.trackToolUsage(toolName, false);
|
||||
telemetry.trackError(
|
||||
error.constructor.name,
|
||||
error.message,
|
||||
toolName,
|
||||
error.message
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// Mock the N8NDocumentationMCPServer to have the server property
|
||||
mcpServer = {
|
||||
server: mockServer,
|
||||
handleTool: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'Success' }] }),
|
||||
executeTool: vi.fn().mockResolvedValue({
|
||||
results: [{ nodeType: 'nodes-base.webhook' }],
|
||||
totalResults: 1
|
||||
}),
|
||||
close: vi.fn()
|
||||
} as any;
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Session tracking', () => {
|
||||
it('should track session start on MCP initialize', async () => {
|
||||
const initializeRequest = {
|
||||
method: 'initialize' as const,
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
clientInfo: {
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
capabilities: {}
|
||||
}
|
||||
};
|
||||
|
||||
// Access the private server instance for testing
|
||||
const server = (mcpServer as any).server;
|
||||
const initializeHandler = server.requestHandlers.get('initialize');
|
||||
|
||||
if (initializeHandler) {
|
||||
await initializeHandler(initializeRequest.params);
|
||||
}
|
||||
|
||||
expect(telemetry.trackSessionStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool usage tracking', () => {
|
||||
it('should track successful tool execution', async () => {
|
||||
const callToolRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'webhook' }
|
||||
}
|
||||
};
|
||||
|
||||
// Mock the executeTool method to return a successful result
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
results: [{ nodeType: 'nodes-base.webhook' }],
|
||||
totalResults: 1
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(callToolRequest.params);
|
||||
}
|
||||
|
||||
expect(telemetry.trackToolUsage).toHaveBeenCalledWith(
|
||||
'search_nodes',
|
||||
true,
|
||||
expect.any(Number)
|
||||
);
|
||||
});
|
||||
|
||||
it('should track failed tool execution', async () => {
|
||||
const callToolRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'get_node',
|
||||
arguments: { nodeType: 'invalid-node' }
|
||||
}
|
||||
};
|
||||
|
||||
// Mock the executeTool method to throw an error
|
||||
const error = new Error('Node not found');
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockRejectedValue(error);
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
try {
|
||||
await callToolHandler(callToolRequest.params);
|
||||
} catch (e) {
|
||||
// Expected to throw
|
||||
}
|
||||
}
|
||||
|
||||
expect(telemetry.trackToolUsage).toHaveBeenCalledWith('get_node', false);
|
||||
expect(telemetry.trackError).toHaveBeenCalledWith(
|
||||
'Error',
|
||||
'Node not found',
|
||||
'get_node'
|
||||
);
|
||||
});
|
||||
|
||||
it('should track tool sequences', async () => {
|
||||
// Set up previous tool state
|
||||
(mcpServer as any).previousTool = 'search_nodes';
|
||||
(mcpServer as any).previousToolTimestamp = Date.now() - 5000;
|
||||
|
||||
const callToolRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'get_node',
|
||||
arguments: { nodeType: 'nodes-base.webhook' }
|
||||
}
|
||||
};
|
||||
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
nodeType: 'nodes-base.webhook',
|
||||
displayName: 'Webhook'
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(callToolRequest.params);
|
||||
}
|
||||
|
||||
expect(telemetry.trackToolSequence).toHaveBeenCalledWith(
|
||||
'search_nodes',
|
||||
'get_node',
|
||||
expect.any(Number)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search query tracking', () => {
|
||||
it('should track search queries with results', async () => {
|
||||
const searchRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'webhook', mode: 'OR' }
|
||||
}
|
||||
};
|
||||
|
||||
// Mock search results
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
results: [
|
||||
{ nodeType: 'nodes-base.webhook', score: 0.95 },
|
||||
{ nodeType: 'nodes-base.httpRequest', score: 0.8 }
|
||||
],
|
||||
totalResults: 2
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(searchRequest.params);
|
||||
}
|
||||
|
||||
expect(telemetry.trackSearchQuery).toHaveBeenCalledWith('webhook', 2, 'OR');
|
||||
});
|
||||
|
||||
it('should track zero-result searches', async () => {
|
||||
const zeroResultRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'nonexistent', mode: 'AND' }
|
||||
}
|
||||
};
|
||||
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
results: [],
|
||||
totalResults: 0
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(zeroResultRequest.params);
|
||||
}
|
||||
|
||||
expect(telemetry.trackSearchQuery).toHaveBeenCalledWith('nonexistent', 0, 'AND');
|
||||
});
|
||||
|
||||
it('should track fallback search queries', async () => {
|
||||
const fallbackRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'partial-match', mode: 'OR' }
|
||||
}
|
||||
};
|
||||
|
||||
// Mock main search with no results, triggering fallback
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
results: [{ nodeType: 'nodes-base.webhook', score: 0.6 }],
|
||||
totalResults: 1,
|
||||
usedFallback: true
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(fallbackRequest.params);
|
||||
}
|
||||
|
||||
// Should track both main query and fallback
|
||||
expect(telemetry.trackSearchQuery).toHaveBeenCalledWith('partial-match', 0, 'OR');
|
||||
expect(telemetry.trackSearchQuery).toHaveBeenCalledWith('partial-match', 1, 'OR_LIKE_FALLBACK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workflow validation tracking', () => {
|
||||
it('should track successful workflow creation', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', type: 'webhook', name: 'Webhook' },
|
||||
{ id: '2', type: 'httpRequest', name: 'HTTP Request' }
|
||||
],
|
||||
connections: {
|
||||
'1': { main: [[{ node: '2', type: 'main', index: 0 }]] }
|
||||
}
|
||||
};
|
||||
|
||||
const validateRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'validate_workflow',
|
||||
arguments: { workflow }
|
||||
}
|
||||
};
|
||||
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
summary: { totalIssues: 0, criticalIssues: 0 }
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(validateRequest.params);
|
||||
}
|
||||
|
||||
expect(telemetry.trackWorkflowCreation).toHaveBeenCalledWith(workflow, true);
|
||||
});
|
||||
|
||||
it('should track validation details for failed workflows', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', type: 'invalid-node', name: 'Invalid Node' }
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const validateRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'validate_workflow',
|
||||
arguments: { workflow }
|
||||
}
|
||||
};
|
||||
|
||||
const validationResult = {
|
||||
isValid: false,
|
||||
errors: [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'invalid-node',
|
||||
category: 'node_validation',
|
||||
severity: 'error',
|
||||
message: 'Unknown node type',
|
||||
details: { type: 'unknown_node_type' }
|
||||
}
|
||||
],
|
||||
warnings: [],
|
||||
summary: { totalIssues: 1, criticalIssues: 1 }
|
||||
};
|
||||
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue(validationResult);
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(validateRequest.params);
|
||||
}
|
||||
|
||||
expect(telemetry.trackValidationDetails).toHaveBeenCalledWith(
|
||||
'invalid-node',
|
||||
'unknown_node_type',
|
||||
expect.objectContaining({
|
||||
category: 'node_validation',
|
||||
severity: 'error'
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Node configuration tracking', () => {
|
||||
it('should track node configuration validation', async () => {
|
||||
const validateNodeRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'validate_node_operation',
|
||||
arguments: {
|
||||
nodeType: 'nodes-base.httpRequest',
|
||||
config: { url: 'https://api.example.com', method: 'GET' }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
nodeConfig: { url: 'https://api.example.com', method: 'GET' }
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(validateNodeRequest.params);
|
||||
}
|
||||
|
||||
// Should track the validation attempt
|
||||
expect(telemetry.trackToolUsage).toHaveBeenCalledWith(
|
||||
'validate_node_operation',
|
||||
true,
|
||||
expect.any(Number)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance metric tracking', () => {
|
||||
it('should track slow tool executions', async () => {
|
||||
const slowToolRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'http', limit: 1000 }
|
||||
}
|
||||
};
|
||||
|
||||
// Mock a slow operation
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockImplementation(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000)); // 2 second delay
|
||||
return { results: [], totalCount: 0 };
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(slowToolRequest.params);
|
||||
}
|
||||
|
||||
expect(telemetry.trackToolUsage).toHaveBeenCalledWith(
|
||||
'search_nodes',
|
||||
true,
|
||||
expect.any(Number)
|
||||
);
|
||||
|
||||
// Verify duration is tracked (should be around 2000ms)
|
||||
const trackUsageCall = vi.mocked(telemetry.trackToolUsage).mock.calls[0];
|
||||
expect(trackUsageCall[2]).toBeGreaterThan(1500); // Allow some variance
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool listing and capabilities', () => {
|
||||
it('should handle tool listing without telemetry interference', async () => {
|
||||
const listToolsRequest: ListToolsRequest = {
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
};
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const listToolsHandler = server.requestHandlers.get('tools/list');
|
||||
|
||||
if (listToolsHandler) {
|
||||
const result = await listToolsHandler(listToolsRequest.params);
|
||||
expect(result).toHaveProperty('tools');
|
||||
expect(Array.isArray(result.tools)).toBe(true);
|
||||
}
|
||||
|
||||
// Tool listing shouldn't generate telemetry events
|
||||
expect(telemetry.trackToolUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling and telemetry', () => {
|
||||
it('should track errors without breaking MCP protocol', async () => {
|
||||
const errorRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'nonexistent_tool',
|
||||
arguments: {}
|
||||
}
|
||||
};
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
try {
|
||||
await callToolHandler(errorRequest.params);
|
||||
} catch (error) {
|
||||
// Error should be handled by MCP server
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
}
|
||||
|
||||
// Should track error without throwing
|
||||
expect(telemetry.trackError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle telemetry errors gracefully', async () => {
|
||||
// Mock telemetry to throw an error
|
||||
vi.mocked(telemetry.trackToolUsage).mockImplementation(() => {
|
||||
throw new Error('Telemetry service unavailable');
|
||||
});
|
||||
|
||||
const callToolRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'webhook' }
|
||||
}
|
||||
};
|
||||
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
results: [],
|
||||
totalResults: 0
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
// Should not throw even if telemetry fails
|
||||
if (callToolHandler) {
|
||||
await expect(callToolHandler(callToolRequest.params)).resolves.toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Telemetry configuration integration', () => {
|
||||
it('should respect telemetry disabled state', async () => {
|
||||
mockTelemetryConfig.isEnabled.mockReturnValue(false);
|
||||
|
||||
const callToolRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'webhook' }
|
||||
}
|
||||
};
|
||||
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
results: [],
|
||||
totalResults: 0
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(callToolRequest.params);
|
||||
}
|
||||
|
||||
// Should still track if telemetry manager handles disabled state
|
||||
// The actual filtering happens in telemetry manager, not MCP server
|
||||
expect(telemetry.trackToolUsage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex workflow scenarios', () => {
|
||||
it('should track comprehensive workflow validation scenario', async () => {
|
||||
const complexWorkflow = {
|
||||
nodes: [
|
||||
{ id: '1', type: 'webhook', name: 'Webhook Trigger' },
|
||||
{ id: '2', type: 'httpRequest', name: 'API Call', parameters: { url: 'https://api.example.com' } },
|
||||
{ id: '3', type: 'set', name: 'Transform Data' },
|
||||
{ id: '4', type: 'if', name: 'Conditional Logic' },
|
||||
{ id: '5', type: 'slack', name: 'Send Notification' }
|
||||
],
|
||||
connections: {
|
||||
'1': { main: [[{ node: '2', type: 'main', index: 0 }]] },
|
||||
'2': { main: [[{ node: '3', type: 'main', index: 0 }]] },
|
||||
'3': { main: [[{ node: '4', type: 'main', index: 0 }]] },
|
||||
'4': { main: [[{ node: '5', type: 'main', index: 0 }]] }
|
||||
}
|
||||
};
|
||||
|
||||
const validateRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'validate_workflow',
|
||||
arguments: { workflow: complexWorkflow }
|
||||
}
|
||||
};
|
||||
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: [
|
||||
{
|
||||
nodeId: '2',
|
||||
nodeType: 'httpRequest',
|
||||
category: 'configuration',
|
||||
severity: 'warning',
|
||||
message: 'Consider adding error handling'
|
||||
}
|
||||
],
|
||||
summary: { totalIssues: 1, criticalIssues: 0 }
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(validateRequest.params);
|
||||
}
|
||||
|
||||
expect(telemetry.trackWorkflowCreation).toHaveBeenCalledWith(complexWorkflow, true);
|
||||
expect(telemetry.trackToolUsage).toHaveBeenCalledWith(
|
||||
'validate_workflow',
|
||||
true,
|
||||
expect.any(Number)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP server lifecycle and telemetry', () => {
|
||||
it('should handle server initialization with telemetry', async () => {
|
||||
// Set up minimal environment for server creation
|
||||
process.env.NODE_DB_PATH = ':memory:';
|
||||
|
||||
// Verify that server creation doesn't interfere with telemetry
|
||||
const newServer = {} as N8NDocumentationMCPServer; // Mock instance
|
||||
expect(newServer).toBeDefined();
|
||||
|
||||
// Telemetry should still be functional
|
||||
expect(telemetry.getMetrics).toBeDefined();
|
||||
expect(typeof telemetry.trackToolUsage).toBe('function');
|
||||
});
|
||||
|
||||
it('should handle concurrent tool executions with telemetry', async () => {
|
||||
const requests = [
|
||||
{
|
||||
method: 'tools/call' as const,
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'webhook' }
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'tools/call' as const,
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'http' }
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'tools/call' as const,
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'database' }
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
results: [{ nodeType: 'test-node' }],
|
||||
totalResults: 1
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await Promise.all(
|
||||
requests.map(req => callToolHandler(req.params))
|
||||
);
|
||||
}
|
||||
|
||||
// All three calls should be tracked
|
||||
expect(telemetry.trackToolUsage).toHaveBeenCalledTimes(3);
|
||||
expect(telemetry.trackSearchQuery).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -445,7 +445,7 @@ describe('Integration: Real-World Type Structure Validation', () => {
|
||||
expect(sheetIdErrors).toBe(0);
|
||||
});
|
||||
|
||||
it('should validate all filter operations including exists/notExists/isNotEmpty', async () => {
|
||||
it('should validate all filter operations including exists/notExists/notEmpty', async () => {
|
||||
const templates = db.prepare(`
|
||||
SELECT id, name, workflow_json_compressed
|
||||
FROM templates
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,328 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock logger
|
||||
vi.mock('../../../src/utils/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
describe('Database Adapter - Unit Tests', () => {
|
||||
describe('DatabaseAdapter Interface', () => {
|
||||
it('should define interface when adapter is created', () => {
|
||||
// This is a type test - ensuring the interface is correctly defined
|
||||
type DatabaseAdapter = {
|
||||
prepare: (sql: string) => any;
|
||||
exec: (sql: string) => void;
|
||||
close: () => void;
|
||||
pragma: (key: string, value?: any) => any;
|
||||
readonly inTransaction: boolean;
|
||||
transaction: <T>(fn: () => T) => T;
|
||||
checkFTS5Support: () => boolean;
|
||||
};
|
||||
|
||||
// Type assertion to ensure interface matches
|
||||
const mockAdapter: DatabaseAdapter = {
|
||||
prepare: vi.fn(),
|
||||
exec: vi.fn(),
|
||||
close: vi.fn(),
|
||||
pragma: vi.fn(),
|
||||
inTransaction: false,
|
||||
transaction: vi.fn((fn) => fn()),
|
||||
checkFTS5Support: vi.fn(() => true)
|
||||
};
|
||||
|
||||
expect(mockAdapter).toBeDefined();
|
||||
expect(mockAdapter.prepare).toBeDefined();
|
||||
expect(mockAdapter.exec).toBeDefined();
|
||||
expect(mockAdapter.close).toBeDefined();
|
||||
expect(mockAdapter.pragma).toBeDefined();
|
||||
expect(mockAdapter.transaction).toBeDefined();
|
||||
expect(mockAdapter.checkFTS5Support).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PreparedStatement Interface', () => {
|
||||
it('should define interface when statement is prepared', () => {
|
||||
// Type test for PreparedStatement
|
||||
type PreparedStatement = {
|
||||
run: (...params: any[]) => { changes: number; lastInsertRowid: number | bigint };
|
||||
get: (...params: any[]) => any;
|
||||
all: (...params: any[]) => any[];
|
||||
iterate: (...params: any[]) => IterableIterator<any>;
|
||||
pluck: (toggle?: boolean) => PreparedStatement;
|
||||
expand: (toggle?: boolean) => PreparedStatement;
|
||||
raw: (toggle?: boolean) => PreparedStatement;
|
||||
columns: () => any[];
|
||||
bind: (...params: any[]) => PreparedStatement;
|
||||
};
|
||||
|
||||
const mockStmt: PreparedStatement = {
|
||||
run: vi.fn(() => ({ changes: 1, lastInsertRowid: 1 })),
|
||||
get: vi.fn(),
|
||||
all: vi.fn(() => []),
|
||||
iterate: vi.fn(function* () {}),
|
||||
pluck: vi.fn(function(this: any) { return this; }),
|
||||
expand: vi.fn(function(this: any) { return this; }),
|
||||
raw: vi.fn(function(this: any) { return this; }),
|
||||
columns: vi.fn(() => []),
|
||||
bind: vi.fn(function(this: any) { return this; })
|
||||
};
|
||||
|
||||
expect(mockStmt).toBeDefined();
|
||||
expect(mockStmt.run).toBeDefined();
|
||||
expect(mockStmt.get).toBeDefined();
|
||||
expect(mockStmt.all).toBeDefined();
|
||||
expect(mockStmt.iterate).toBeDefined();
|
||||
expect(mockStmt.pluck).toBeDefined();
|
||||
expect(mockStmt.expand).toBeDefined();
|
||||
expect(mockStmt.raw).toBeDefined();
|
||||
expect(mockStmt.columns).toBeDefined();
|
||||
expect(mockStmt.bind).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FTS5 Support Detection', () => {
|
||||
it('should detect support when FTS5 module is available', () => {
|
||||
const mockDb = {
|
||||
exec: vi.fn()
|
||||
};
|
||||
|
||||
// Function to test FTS5 support detection logic
|
||||
const checkFTS5Support = (db: any): boolean => {
|
||||
try {
|
||||
db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS test_fts5 USING fts5(content);");
|
||||
db.exec("DROP TABLE IF EXISTS test_fts5;");
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Test when FTS5 is supported
|
||||
expect(checkFTS5Support(mockDb)).toBe(true);
|
||||
expect(mockDb.exec).toHaveBeenCalledWith(
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS test_fts5 USING fts5(content);"
|
||||
);
|
||||
|
||||
// Test when FTS5 is not supported
|
||||
mockDb.exec.mockImplementation(() => {
|
||||
throw new Error('no such module: fts5');
|
||||
});
|
||||
|
||||
expect(checkFTS5Support(mockDb)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Transaction Handling', () => {
|
||||
it('should handle commit and rollback when transaction is executed', () => {
|
||||
// Test transaction wrapper logic
|
||||
const mockDb = {
|
||||
exec: vi.fn(),
|
||||
inTransaction: false
|
||||
};
|
||||
|
||||
const transaction = <T>(db: any, fn: () => T): T => {
|
||||
try {
|
||||
db.exec('BEGIN');
|
||||
db.inTransaction = true;
|
||||
const result = fn();
|
||||
db.exec('COMMIT');
|
||||
db.inTransaction = false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
db.inTransaction = false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Test successful transaction
|
||||
const result = transaction(mockDb, () => 'success');
|
||||
expect(result).toBe('success');
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('BEGIN');
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('COMMIT');
|
||||
expect(mockDb.inTransaction).toBe(false);
|
||||
|
||||
// Reset mocks
|
||||
mockDb.exec.mockClear();
|
||||
|
||||
// Test failed transaction
|
||||
expect(() => {
|
||||
transaction(mockDb, () => {
|
||||
throw new Error('transaction error');
|
||||
});
|
||||
}).toThrow('transaction error');
|
||||
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('BEGIN');
|
||||
expect(mockDb.exec).toHaveBeenCalledWith('ROLLBACK');
|
||||
expect(mockDb.inTransaction).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pragma Handling', () => {
|
||||
it('should return values when pragma commands are executed', () => {
|
||||
const mockDb = {
|
||||
pragma: vi.fn((key: string, value?: any) => {
|
||||
if (key === 'journal_mode' && value === 'WAL') {
|
||||
return 'wal';
|
||||
}
|
||||
return null;
|
||||
})
|
||||
};
|
||||
|
||||
expect(mockDb.pragma('journal_mode', 'WAL')).toBe('wal');
|
||||
expect(mockDb.pragma('other_key')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SQLJSAdapter Save Behavior (Memory Leak Fix - Issue #330)', () => {
|
||||
it('should use default 5000ms save interval when env var not set', () => {
|
||||
// Verify default interval is 5000ms (not old 100ms)
|
||||
const DEFAULT_INTERVAL = 5000;
|
||||
expect(DEFAULT_INTERVAL).toBe(5000);
|
||||
});
|
||||
|
||||
it('should use custom save interval from SQLJS_SAVE_INTERVAL_MS env var', () => {
|
||||
// Mock environment variable
|
||||
const originalEnv = process.env.SQLJS_SAVE_INTERVAL_MS;
|
||||
process.env.SQLJS_SAVE_INTERVAL_MS = '10000';
|
||||
|
||||
// Test that interval would be parsed
|
||||
const envInterval = process.env.SQLJS_SAVE_INTERVAL_MS;
|
||||
const parsedInterval = envInterval ? parseInt(envInterval, 10) : 5000;
|
||||
|
||||
expect(parsedInterval).toBe(10000);
|
||||
|
||||
// Restore environment
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.SQLJS_SAVE_INTERVAL_MS = originalEnv;
|
||||
} else {
|
||||
delete process.env.SQLJS_SAVE_INTERVAL_MS;
|
||||
}
|
||||
});
|
||||
|
||||
it('should fall back to default when invalid env var is provided', () => {
|
||||
// Test validation logic
|
||||
const testCases = [
|
||||
{ input: 'invalid', expected: 5000 },
|
||||
{ input: '50', expected: 5000 }, // Too low (< 100)
|
||||
{ input: '-100', expected: 5000 }, // Negative
|
||||
{ input: '0', expected: 5000 }, // Zero
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, expected }) => {
|
||||
const parsed = parseInt(input, 10);
|
||||
const interval = (isNaN(parsed) || parsed < 100) ? 5000 : parsed;
|
||||
expect(interval).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it('should debounce multiple rapid saves using configured interval', () => {
|
||||
// Test debounce logic
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
const mockSave = vi.fn();
|
||||
|
||||
const scheduleSave = (interval: number) => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
mockSave();
|
||||
}, interval);
|
||||
};
|
||||
|
||||
// Simulate rapid operations
|
||||
scheduleSave(5000);
|
||||
scheduleSave(5000);
|
||||
scheduleSave(5000);
|
||||
|
||||
// Should only schedule once (debounced)
|
||||
expect(mockSave).not.toHaveBeenCalled();
|
||||
|
||||
// Cleanup
|
||||
if (timer) clearTimeout(timer);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SQLJSAdapter Memory Optimization', () => {
|
||||
it('should not use Buffer.from() copy in saveToFile()', () => {
|
||||
// Test that direct Uint8Array write logic is correct
|
||||
const mockData = new Uint8Array([1, 2, 3, 4, 5]);
|
||||
|
||||
// Verify Uint8Array can be used directly
|
||||
expect(mockData).toBeInstanceOf(Uint8Array);
|
||||
expect(mockData.length).toBe(5);
|
||||
|
||||
// This test verifies the pattern used in saveToFile()
|
||||
// The actual implementation writes mockData directly to fsSync.writeFileSync()
|
||||
// without using Buffer.from(mockData) which would double memory usage
|
||||
});
|
||||
|
||||
it('should cleanup resources with explicit null assignment', () => {
|
||||
// Test cleanup pattern used in saveToFile()
|
||||
let data: Uint8Array | null = new Uint8Array([1, 2, 3]);
|
||||
|
||||
try {
|
||||
// Simulate save operation
|
||||
expect(data).not.toBeNull();
|
||||
} finally {
|
||||
// Explicit cleanup helps GC
|
||||
data = null;
|
||||
}
|
||||
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle save errors without leaking resources', () => {
|
||||
// Test error handling with cleanup
|
||||
let data: Uint8Array | null = null;
|
||||
let errorThrown = false;
|
||||
|
||||
try {
|
||||
data = new Uint8Array([1, 2, 3]);
|
||||
// Simulate error
|
||||
throw new Error('Save failed');
|
||||
} catch (error) {
|
||||
errorThrown = true;
|
||||
} finally {
|
||||
// Cleanup happens even on error
|
||||
data = null;
|
||||
}
|
||||
|
||||
expect(errorThrown).toBe(true);
|
||||
expect(data).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Read vs Write Operation Handling', () => {
|
||||
it('should not trigger save on read-only prepare() calls', () => {
|
||||
// Test that prepare() doesn't schedule save
|
||||
// Only exec() and SQLJSStatement.run() should trigger saves
|
||||
|
||||
const mockScheduleSave = vi.fn();
|
||||
|
||||
// Simulate prepare() - should NOT call scheduleSave
|
||||
// prepare() just creates statement, doesn't modify DB
|
||||
|
||||
// Simulate exec() - SHOULD call scheduleSave
|
||||
mockScheduleSave();
|
||||
|
||||
expect(mockScheduleSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should trigger save on write operations (INSERT/UPDATE/DELETE)', () => {
|
||||
const mockScheduleSave = vi.fn();
|
||||
|
||||
// Simulate write operations
|
||||
mockScheduleSave(); // INSERT
|
||||
mockScheduleSave(); // UPDATE
|
||||
mockScheduleSave(); // DELETE
|
||||
|
||||
expect(mockScheduleSave).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -119,6 +119,11 @@ class MockPreparedStatement implements PreparedStatement {
|
||||
});
|
||||
}
|
||||
|
||||
// saveNode - SELECT existing doc fields before upsert
|
||||
if (this.sql.includes('SELECT npm_readme, ai_documentation_summary, ai_summary_generated_at FROM nodes')) {
|
||||
this.get = vi.fn(() => undefined); // No existing row by default
|
||||
}
|
||||
|
||||
// saveNode - INSERT OR REPLACE
|
||||
if (this.sql.includes('INSERT OR REPLACE INTO nodes')) {
|
||||
this.run = vi.fn((...params: any[]): RunResult => {
|
||||
|
||||
@@ -49,7 +49,12 @@ class MockPreparedStatement implements PreparedStatement {
|
||||
if (sql.includes('SELECT * FROM nodes WHERE node_type = ?')) {
|
||||
this.get = vi.fn((nodeType: string) => this.mockData.get(`node:${nodeType}`));
|
||||
}
|
||||
|
||||
|
||||
// Configure get() for saveNode's SELECT to preserve existing doc fields
|
||||
if (sql.includes('SELECT npm_readme, ai_documentation_summary, ai_summary_generated_at FROM nodes')) {
|
||||
this.get = vi.fn(() => undefined); // No existing row by default
|
||||
}
|
||||
|
||||
// Configure all() for getAITools
|
||||
if (sql.includes('WHERE is_ai_tool = 1')) {
|
||||
this.all = vi.fn(() => this.mockData.get('ai_tools') || []);
|
||||
@@ -123,7 +128,10 @@ describe('NodeRepository - Core Functionality', () => {
|
||||
null, // npmPackageName
|
||||
null, // npmVersion
|
||||
0, // npmDownloads
|
||||
null // communityFetchedAt
|
||||
null, // communityFetchedAt
|
||||
null, // npm_readme (preserved from existing)
|
||||
null, // ai_documentation_summary (preserved from existing)
|
||||
null // ai_summary_generated_at (preserved from existing)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -15,8 +15,21 @@ describe('NodeRepository - Outputs Handling', () => {
|
||||
all: vi.fn()
|
||||
};
|
||||
|
||||
// saveNode now calls prepare twice: first a SELECT (returns get), then INSERT (returns run).
|
||||
// We create a separate mock for the SELECT statement that returns undefined (no existing row).
|
||||
const selectStatement = {
|
||||
run: vi.fn(),
|
||||
get: vi.fn().mockReturnValue(undefined),
|
||||
all: vi.fn()
|
||||
};
|
||||
|
||||
mockDb = {
|
||||
prepare: vi.fn().mockReturnValue(mockStatement),
|
||||
prepare: vi.fn((sql: string) => {
|
||||
if (sql.includes('SELECT npm_readme')) {
|
||||
return selectStatement;
|
||||
}
|
||||
return mockStatement;
|
||||
}),
|
||||
transaction: vi.fn(),
|
||||
exec: vi.fn(),
|
||||
close: vi.fn(),
|
||||
@@ -55,18 +68,9 @@ describe('NodeRepository - Outputs Handling', () => {
|
||||
|
||||
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, is_tool_variant, tool_variant_of,
|
||||
has_tool_variant, version, documentation,
|
||||
properties_schema, operations, credentials_required,
|
||||
outputs, output_names,
|
||||
is_community, is_verified, author_name, author_github_url,
|
||||
npm_package_name, npm_version, npm_downloads, community_fetched_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
expect(mockDb.prepare).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT OR REPLACE INTO nodes')
|
||||
);
|
||||
|
||||
expect(mockStatement.run).toHaveBeenCalledWith(
|
||||
'nodes-base.splitInBatches',
|
||||
@@ -96,7 +100,10 @@ describe('NodeRepository - Outputs Handling', () => {
|
||||
null, // npm_package_name
|
||||
null, // npm_version
|
||||
0, // npm_downloads
|
||||
null // community_fetched_at
|
||||
null, // community_fetched_at
|
||||
null, // npm_readme (preserved from existing)
|
||||
null, // ai_documentation_summary (preserved from existing)
|
||||
null // ai_summary_generated_at (preserved from existing)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { getNodeTypes, mockNodeBehavior, resetAllMocks } from '../__mocks__/n8n-nodes-base';
|
||||
|
||||
// Example service that uses n8n-nodes-base
|
||||
class WorkflowService {
|
||||
async getNodeDescription(nodeName: string) {
|
||||
const nodeTypes = getNodeTypes();
|
||||
const node = nodeTypes.getByName(nodeName);
|
||||
return node?.description;
|
||||
}
|
||||
|
||||
async executeNode(nodeName: string, context: any) {
|
||||
const nodeTypes = getNodeTypes();
|
||||
const node = nodeTypes.getByName(nodeName);
|
||||
|
||||
if (!node?.execute) {
|
||||
throw new Error(`Node ${nodeName} does not have an execute method`);
|
||||
}
|
||||
|
||||
return node.execute.call(context);
|
||||
}
|
||||
|
||||
async validateSlackMessage(channel: string, text: string) {
|
||||
if (!channel || !text) {
|
||||
throw new Error('Channel and text are required');
|
||||
}
|
||||
|
||||
const nodeTypes = getNodeTypes();
|
||||
const slackNode = nodeTypes.getByName('slack');
|
||||
|
||||
if (!slackNode) {
|
||||
throw new Error('Slack node not found');
|
||||
}
|
||||
|
||||
// Check if required properties exist
|
||||
const channelProp = slackNode.description.properties.find(p => p.name === 'channel');
|
||||
const textProp = slackNode.description.properties.find(p => p.name === 'text');
|
||||
|
||||
return !!(channelProp && textProp);
|
||||
}
|
||||
}
|
||||
|
||||
// Mock the module at the top level
|
||||
vi.mock('n8n-nodes-base', () => {
|
||||
const { getNodeTypes: mockGetNodeTypes } = require('../__mocks__/n8n-nodes-base');
|
||||
return {
|
||||
getNodeTypes: mockGetNodeTypes
|
||||
};
|
||||
});
|
||||
|
||||
describe('WorkflowService with n8n-nodes-base mock', () => {
|
||||
let service: WorkflowService;
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllMocks();
|
||||
service = new WorkflowService();
|
||||
});
|
||||
|
||||
describe('getNodeDescription', () => {
|
||||
it('should get webhook node description', async () => {
|
||||
const description = await service.getNodeDescription('webhook');
|
||||
|
||||
expect(description).toBeDefined();
|
||||
expect(description?.name).toBe('webhook');
|
||||
expect(description?.group).toContain('trigger');
|
||||
expect(description?.webhooks).toBeDefined();
|
||||
});
|
||||
|
||||
it('should get httpRequest node description', async () => {
|
||||
const description = await service.getNodeDescription('httpRequest');
|
||||
|
||||
expect(description).toBeDefined();
|
||||
expect(description?.name).toBe('httpRequest');
|
||||
expect(description?.version).toBe(3);
|
||||
|
||||
const methodProp = description?.properties.find(p => p.name === 'method');
|
||||
expect(methodProp).toBeDefined();
|
||||
expect(methodProp?.options).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeNode', () => {
|
||||
it('should execute httpRequest node with custom response', async () => {
|
||||
// Override the httpRequest node behavior for this test
|
||||
mockNodeBehavior('httpRequest', {
|
||||
execute: vi.fn(async function(this: any) {
|
||||
const url = this.getNodeParameter('url', 0);
|
||||
return [[{
|
||||
json: {
|
||||
statusCode: 200,
|
||||
url,
|
||||
customData: 'mocked response'
|
||||
}
|
||||
}]];
|
||||
})
|
||||
});
|
||||
|
||||
const mockContext = {
|
||||
getInputData: vi.fn(() => [{ json: { input: 'data' } }]),
|
||||
getNodeParameter: vi.fn((name: string) => {
|
||||
if (name === 'url') return 'https://test.com/api';
|
||||
return '';
|
||||
})
|
||||
};
|
||||
|
||||
const result = await service.executeNode('httpRequest', mockContext);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result[0][0].json).toMatchObject({
|
||||
statusCode: 200,
|
||||
url: 'https://test.com/api',
|
||||
customData: 'mocked response'
|
||||
});
|
||||
});
|
||||
|
||||
it('should execute slack node and track calls', async () => {
|
||||
const mockContext = {
|
||||
getInputData: vi.fn(() => [{ json: { message: 'test' } }]),
|
||||
getNodeParameter: vi.fn((name: string, index: number) => {
|
||||
const params: Record<string, string> = {
|
||||
resource: 'message',
|
||||
operation: 'post',
|
||||
channel: '#general',
|
||||
text: 'Hello from test!'
|
||||
};
|
||||
return params[name] || '';
|
||||
}),
|
||||
getCredentials: vi.fn(async () => ({ token: 'mock-token' }))
|
||||
};
|
||||
|
||||
const result = await service.executeNode('slack', mockContext);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result[0][0].json).toMatchObject({
|
||||
ok: true,
|
||||
channel: '#general',
|
||||
message: {
|
||||
text: 'Hello from test!'
|
||||
}
|
||||
});
|
||||
|
||||
// Verify the mock was called
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('channel', 0, '');
|
||||
expect(mockContext.getNodeParameter).toHaveBeenCalledWith('text', 0, '');
|
||||
});
|
||||
|
||||
it('should throw error for non-executable node', async () => {
|
||||
// Create a trigger-only node
|
||||
mockNodeBehavior('webhook', {
|
||||
execute: undefined // Remove execute method
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.executeNode('webhook', {})
|
||||
).rejects.toThrow('Node webhook does not have an execute method');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSlackMessage', () => {
|
||||
it('should validate slack message parameters', async () => {
|
||||
const isValid = await service.validateSlackMessage('#general', 'Hello');
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw error for missing parameters', async () => {
|
||||
await expect(
|
||||
service.validateSlackMessage('', 'Hello')
|
||||
).rejects.toThrow('Channel and text are required');
|
||||
|
||||
await expect(
|
||||
service.validateSlackMessage('#general', '')
|
||||
).rejects.toThrow('Channel and text are required');
|
||||
});
|
||||
|
||||
it('should handle missing slack node', async () => {
|
||||
// Save the original mock implementation
|
||||
const originalImplementation = vi.mocked(getNodeTypes).getMockImplementation();
|
||||
|
||||
// Override getNodeTypes to return undefined for slack
|
||||
vi.mocked(getNodeTypes).mockImplementation(() => ({
|
||||
getByName: vi.fn((name: string) => {
|
||||
if (name === 'slack') return undefined;
|
||||
// Return the actual mock implementation for other nodes
|
||||
const actualRegistry = originalImplementation ? originalImplementation() : getNodeTypes();
|
||||
return actualRegistry.getByName(name);
|
||||
}),
|
||||
getByNameAndVersion: vi.fn()
|
||||
}));
|
||||
|
||||
await expect(
|
||||
service.validateSlackMessage('#general', 'Hello')
|
||||
).rejects.toThrow('Slack node not found');
|
||||
|
||||
// Restore the original implementation
|
||||
if (originalImplementation) {
|
||||
vi.mocked(getNodeTypes).mockImplementation(originalImplementation);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('complex workflow scenarios', () => {
|
||||
it('should handle if node branching', async () => {
|
||||
const mockContext = {
|
||||
getInputData: vi.fn(() => [
|
||||
{ json: { status: 'active' } },
|
||||
{ json: { status: 'inactive' } },
|
||||
{ json: { status: 'active' } },
|
||||
]),
|
||||
getNodeParameter: vi.fn()
|
||||
};
|
||||
|
||||
const result = await service.executeNode('if', mockContext);
|
||||
|
||||
expect(result).toHaveLength(2); // true and false branches
|
||||
expect(result[0]).toHaveLength(2); // items at index 0 and 2
|
||||
expect(result[1]).toHaveLength(1); // item at index 1
|
||||
});
|
||||
|
||||
it('should handle merge node combining inputs', async () => {
|
||||
const mockContext = {
|
||||
getInputData: vi.fn((inputIndex?: number) => {
|
||||
if (inputIndex === 0) return [{ json: { source: 'input1' } }];
|
||||
if (inputIndex === 1) return [{ json: { source: 'input2' } }];
|
||||
return [{ json: { source: 'input1' } }];
|
||||
}),
|
||||
getNodeParameter: vi.fn(() => 'append')
|
||||
};
|
||||
|
||||
const result = await service.executeNode('merge', mockContext);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result[0]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { SingleSessionHTTPServer } from '../../src/http-server-single-session';
|
||||
import express from 'express';
|
||||
|
||||
describe('HTTP Server n8n Re-initialization', () => {
|
||||
let server: SingleSessionHTTPServer;
|
||||
let app: express.Application;
|
||||
|
||||
beforeEach(() => {
|
||||
// Set required environment variables for testing
|
||||
process.env.AUTH_TOKEN = 'test-token-32-chars-minimum-length-for-security';
|
||||
process.env.NODE_DB_PATH = ':memory:';
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
await server.shutdown();
|
||||
}
|
||||
// Clean up environment
|
||||
delete process.env.AUTH_TOKEN;
|
||||
delete process.env.NODE_DB_PATH;
|
||||
});
|
||||
|
||||
it('should handle re-initialization requests gracefully', async () => {
|
||||
// Create mock request and response
|
||||
const mockReq = {
|
||||
method: 'POST',
|
||||
url: '/mcp',
|
||||
headers: {},
|
||||
body: {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: { tools: {} },
|
||||
clientInfo: { name: 'n8n', version: '1.0.0' }
|
||||
}
|
||||
},
|
||||
get: (header: string) => {
|
||||
if (header === 'user-agent') return 'test-agent';
|
||||
if (header === 'content-length') return '100';
|
||||
if (header === 'content-type') return 'application/json';
|
||||
return undefined;
|
||||
},
|
||||
ip: '127.0.0.1'
|
||||
} as any;
|
||||
|
||||
const mockRes = {
|
||||
headersSent: false,
|
||||
statusCode: 200,
|
||||
finished: false,
|
||||
status: (code: number) => mockRes,
|
||||
json: (data: any) => mockRes,
|
||||
setHeader: (name: string, value: string) => mockRes,
|
||||
end: () => mockRes
|
||||
} as any;
|
||||
|
||||
try {
|
||||
server = new SingleSessionHTTPServer();
|
||||
|
||||
// First request should work
|
||||
await server.handleRequest(mockReq, mockRes);
|
||||
expect(mockRes.statusCode).toBe(200);
|
||||
|
||||
// Second request (re-initialization) should also work
|
||||
mockReq.body.id = 2;
|
||||
await server.handleRequest(mockReq, mockRes);
|
||||
expect(mockRes.statusCode).toBe(200);
|
||||
|
||||
} catch (error) {
|
||||
// This test mainly ensures the logic doesn't throw errors
|
||||
// The actual MCP communication would need a more complex setup
|
||||
console.log('Expected error in unit test environment:', error);
|
||||
expect(error).toBeDefined(); // We expect some error due to simplified mock setup
|
||||
}
|
||||
});
|
||||
|
||||
it('should identify initialize requests correctly', () => {
|
||||
const initializeRequest = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {}
|
||||
};
|
||||
|
||||
const nonInitializeRequest = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/list'
|
||||
};
|
||||
|
||||
// Test the logic we added for detecting initialize requests
|
||||
const isInitReq1 = initializeRequest &&
|
||||
initializeRequest.method === 'initialize' &&
|
||||
initializeRequest.jsonrpc === '2.0';
|
||||
|
||||
const isInitReq2 = nonInitializeRequest &&
|
||||
nonInitializeRequest.method === 'initialize' &&
|
||||
nonInitializeRequest.jsonrpc === '2.0';
|
||||
|
||||
expect(isInitReq1).toBe(true);
|
||||
expect(isInitReq2).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -334,14 +334,14 @@ describe('HTTP Server Session Management', () => {
|
||||
server = new SingleSessionHTTPServer();
|
||||
|
||||
// Mock expired sessions
|
||||
// Note: Default session timeout is 5 minutes (configurable via SESSION_TIMEOUT_MINUTES)
|
||||
// Note: Default session timeout is 30 minutes (configurable via SESSION_TIMEOUT_MINUTES)
|
||||
const mockSessionMetadata = {
|
||||
'session-1': {
|
||||
lastAccess: new Date(Date.now() - 10 * 60 * 1000), // 10 minutes ago (expired with 5 min timeout)
|
||||
lastAccess: new Date(Date.now() - 45 * 60 * 1000), // 45 minutes ago (expired with 30 min timeout)
|
||||
createdAt: new Date(Date.now() - 60 * 60 * 1000)
|
||||
},
|
||||
'session-2': {
|
||||
lastAccess: new Date(Date.now() - 2 * 60 * 1000), // 2 minutes ago (not expired with 5 min timeout)
|
||||
lastAccess: new Date(Date.now() - 10 * 60 * 1000), // 10 minutes ago (not expired with 30 min timeout)
|
||||
createdAt: new Date(Date.now() - 20 * 60 * 1000)
|
||||
}
|
||||
};
|
||||
@@ -517,15 +517,15 @@ describe('HTTP Server Session Management', () => {
|
||||
it('should get session metrics correctly', async () => {
|
||||
server = new SingleSessionHTTPServer();
|
||||
|
||||
// Note: Default session timeout is 5 minutes (configurable via SESSION_TIMEOUT_MINUTES)
|
||||
// Note: Default session timeout is 30 minutes (configurable via SESSION_TIMEOUT_MINUTES)
|
||||
const now = Date.now();
|
||||
(server as any).sessionMetadata = {
|
||||
'active-session': {
|
||||
lastAccess: new Date(now - 2 * 60 * 1000), // 2 minutes ago (not expired with 5 min timeout)
|
||||
lastAccess: new Date(now - 10 * 60 * 1000), // 10 minutes ago (not expired with 30 min timeout)
|
||||
createdAt: new Date(now - 20 * 60 * 1000)
|
||||
},
|
||||
'expired-session': {
|
||||
lastAccess: new Date(now - 10 * 60 * 1000), // 10 minutes ago (expired with 5 min timeout)
|
||||
lastAccess: new Date(now - 45 * 60 * 1000), // 45 minutes ago (expired with 30 min timeout)
|
||||
createdAt: new Date(now - 60 * 60 * 1000)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,10 +27,10 @@ describe('SingleSessionHTTPServer - Session Persistence', () => {
|
||||
|
||||
// Use current timestamps to avoid expiration
|
||||
const now = new Date();
|
||||
const createdAt1 = new Date(now.getTime() - 10 * 60 * 1000); // 10 minutes ago
|
||||
const lastAccess1 = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago
|
||||
const createdAt2 = new Date(now.getTime() - 15 * 60 * 1000); // 15 minutes ago
|
||||
const lastAccess2 = new Date(now.getTime() - 3 * 60 * 1000); // 3 minutes ago
|
||||
const createdAt1 = new Date(now.getTime() - 2 * 60 * 1000); // 2 minutes ago
|
||||
const lastAccess1 = new Date(now.getTime() - 30 * 1000); // 30 seconds ago
|
||||
const createdAt2 = new Date(now.getTime() - 3 * 60 * 1000); // 3 minutes ago
|
||||
const lastAccess2 = new Date(now.getTime() - 20 * 1000); // 20 seconds ago
|
||||
|
||||
// Access private properties for testing
|
||||
const serverAny = server as any;
|
||||
@@ -101,8 +101,8 @@ describe('SingleSessionHTTPServer - Session Persistence', () => {
|
||||
|
||||
// Create an active session (accessed recently)
|
||||
serverAny.sessionMetadata['active-session'] = {
|
||||
createdAt: new Date(now - 10 * 60 * 1000), // 10 minutes ago
|
||||
lastAccess: new Date(now - 5 * 60 * 1000) // 5 minutes ago
|
||||
createdAt: new Date(now - 2 * 60 * 1000), // 2 minutes ago
|
||||
lastAccess: new Date(now - 30 * 1000) // 30 seconds ago
|
||||
};
|
||||
serverAny.sessionContexts['active-session'] = {
|
||||
n8nApiUrl: 'https://active.example.com',
|
||||
@@ -257,8 +257,8 @@ describe('SingleSessionHTTPServer - Session Persistence', () => {
|
||||
{
|
||||
sessionId: 'active-session',
|
||||
metadata: {
|
||||
createdAt: new Date(now - 10 * 60 * 1000).toISOString(),
|
||||
lastAccess: new Date(now - 5 * 60 * 1000).toISOString()
|
||||
createdAt: new Date(now - 2 * 60 * 1000).toISOString(),
|
||||
lastAccess: new Date(now - 30 * 1000).toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://active.example.com',
|
||||
@@ -465,8 +465,8 @@ describe('SingleSessionHTTPServer - Session Persistence', () => {
|
||||
it('should parse ISO 8601 timestamps correctly', () => {
|
||||
// Use current timestamps to avoid expiration
|
||||
const now = new Date();
|
||||
const createdAtDate = new Date(now.getTime() - 10 * 60 * 1000); // 10 minutes ago
|
||||
const lastAccessDate = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago
|
||||
const createdAtDate = new Date(now.getTime() - 2 * 60 * 1000); // 2 minutes ago
|
||||
const lastAccessDate = new Date(now.getTime() - 30 * 1000); // 30 seconds ago
|
||||
const createdAt = createdAtDate.toISOString();
|
||||
const lastAccess = lastAccessDate.toISOString();
|
||||
|
||||
@@ -500,8 +500,8 @@ describe('SingleSessionHTTPServer - Session Persistence', () => {
|
||||
// Create sessions with current timestamps
|
||||
const serverAny = server as any;
|
||||
const now = new Date();
|
||||
const createdAt = new Date(now.getTime() - 10 * 60 * 1000); // 10 minutes ago
|
||||
const lastAccess = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago
|
||||
const createdAt = new Date(now.getTime() - 60 * 1000); // 1 minute ago
|
||||
const lastAccess = new Date(now.getTime() - 30 * 1000); // 30 seconds ago
|
||||
|
||||
serverAny.sessionMetadata['session-1'] = {
|
||||
createdAt,
|
||||
|
||||
@@ -128,8 +128,8 @@ describe('N8NMCPEngine - Session Persistence', () => {
|
||||
{
|
||||
sessionId: 'valid-1',
|
||||
metadata: {
|
||||
createdAt: new Date(now - 10 * 60 * 1000).toISOString(),
|
||||
lastAccess: new Date(now - 5 * 60 * 1000).toISOString()
|
||||
createdAt: new Date(now - 2 * 60 * 1000).toISOString(),
|
||||
lastAccess: new Date(now - 30 * 1000).toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://valid1.example.com',
|
||||
@@ -140,8 +140,8 @@ describe('N8NMCPEngine - Session Persistence', () => {
|
||||
{
|
||||
sessionId: 'valid-2',
|
||||
metadata: {
|
||||
createdAt: new Date(now - 10 * 60 * 1000).toISOString(),
|
||||
lastAccess: new Date(now - 5 * 60 * 1000).toISOString()
|
||||
createdAt: new Date(now - 2 * 60 * 1000).toISOString(),
|
||||
lastAccess: new Date(now - 30 * 1000).toISOString()
|
||||
},
|
||||
context: {
|
||||
n8nApiUrl: 'https://valid2.example.com',
|
||||
@@ -177,8 +177,8 @@ describe('N8NMCPEngine - Session Persistence', () => {
|
||||
const serverAny = server as any;
|
||||
|
||||
const now = new Date();
|
||||
const createdAt = new Date(now.getTime() - 10 * 60 * 1000); // 10 minutes ago
|
||||
const lastAccess = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago
|
||||
const createdAt = new Date(now.getTime() - 60 * 1000); // 1 minute ago
|
||||
const lastAccess = new Date(now.getTime() - 30 * 1000); // 30 seconds ago
|
||||
|
||||
serverAny.sessionMetadata['engine-session'] = {
|
||||
createdAt,
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
/**
|
||||
* Simple, focused unit tests for handlers-n8n-manager.ts coverage gaps
|
||||
*
|
||||
* This test file focuses on specific uncovered lines to achieve >95% coverage
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
describe('handlers-n8n-manager Simple Coverage Tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Cache Key Generation', () => {
|
||||
it('should generate deterministic SHA-256 hashes', () => {
|
||||
const input1 = 'https://api.n8n.cloud:key123:instance1';
|
||||
const input2 = 'https://api.n8n.cloud:key123:instance1';
|
||||
const input3 = 'https://api.n8n.cloud:key456:instance2';
|
||||
|
||||
const hash1 = createHash('sha256').update(input1).digest('hex');
|
||||
const hash2 = createHash('sha256').update(input2).digest('hex');
|
||||
const hash3 = createHash('sha256').update(input3).digest('hex');
|
||||
|
||||
// Same input should produce same hash
|
||||
expect(hash1).toBe(hash2);
|
||||
// Different input should produce different hash
|
||||
expect(hash1).not.toBe(hash3);
|
||||
// Hash should be 64 characters (SHA-256)
|
||||
expect(hash1).toHaveLength(64);
|
||||
expect(hash1).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('should handle empty instanceId in cache key generation', () => {
|
||||
const url = 'https://api.n8n.cloud';
|
||||
const key = 'test-key';
|
||||
const instanceId = '';
|
||||
|
||||
const cacheInput = `${url}:${key}:${instanceId}`;
|
||||
const hash = createHash('sha256').update(cacheInput).digest('hex');
|
||||
|
||||
expect(hash).toBeDefined();
|
||||
expect(hash).toHaveLength(64);
|
||||
});
|
||||
|
||||
it('should handle undefined values in cache key generation', () => {
|
||||
const url = 'https://api.n8n.cloud';
|
||||
const key = 'test-key';
|
||||
const instanceId = undefined;
|
||||
|
||||
// This simulates the actual cache key generation in the code
|
||||
const cacheInput = `${url}:${key}:${instanceId || ''}`;
|
||||
const hash = createHash('sha256').update(cacheInput).digest('hex');
|
||||
|
||||
expect(hash).toBeDefined();
|
||||
expect(cacheInput).toBe('https://api.n8n.cloud:test-key:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL Sanitization', () => {
|
||||
it('should sanitize URLs for logging', () => {
|
||||
const fullUrl = 'https://secret.example.com/api/v1/private';
|
||||
|
||||
// This simulates the URL sanitization in the logging code
|
||||
const sanitizedUrl = fullUrl.replace(/^(https?:\/\/[^\/]+).*/, '$1');
|
||||
|
||||
expect(sanitizedUrl).toBe('https://secret.example.com');
|
||||
expect(sanitizedUrl).not.toContain('/api/v1/private');
|
||||
});
|
||||
|
||||
it('should handle various URL formats in sanitization', () => {
|
||||
const testUrls = [
|
||||
'https://api.n8n.cloud',
|
||||
'https://api.n8n.cloud/',
|
||||
'https://api.n8n.cloud/webhook/abc123',
|
||||
'http://localhost:5678/api/v1',
|
||||
'https://subdomain.domain.com/path/to/resource'
|
||||
];
|
||||
|
||||
testUrls.forEach(url => {
|
||||
const sanitized = url.replace(/^(https?:\/\/[^\/]+).*/, '$1');
|
||||
|
||||
// Should contain protocol and domain only
|
||||
expect(sanitized).toMatch(/^https?:\/\/[^\/]+$/);
|
||||
// Should not contain paths (but domain names containing 'api' are OK)
|
||||
expect(sanitized).not.toContain('/webhook');
|
||||
if (!sanitized.includes('api.n8n.cloud')) {
|
||||
expect(sanitized).not.toContain('/api');
|
||||
}
|
||||
expect(sanitized).not.toContain('/path');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cache Key Partial Logging', () => {
|
||||
it('should create partial cache key for logging', () => {
|
||||
const fullHash = 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890';
|
||||
|
||||
// This simulates the partial key logging in the dispose callback
|
||||
const partialKey = fullHash.substring(0, 8) + '...';
|
||||
|
||||
expect(partialKey).toBe('abcdef12...');
|
||||
expect(partialKey).toHaveLength(11);
|
||||
expect(partialKey).toMatch(/^[a-f0-9]{8}\.\.\.$/);
|
||||
});
|
||||
|
||||
it('should handle various hash lengths for partial logging', () => {
|
||||
const hashes = [
|
||||
'a'.repeat(64),
|
||||
'b'.repeat(32),
|
||||
'c'.repeat(16),
|
||||
'd'.repeat(8)
|
||||
];
|
||||
|
||||
hashes.forEach(hash => {
|
||||
const partial = hash.substring(0, 8) + '...';
|
||||
expect(partial).toHaveLength(11);
|
||||
expect(partial.endsWith('...')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Message Handling', () => {
|
||||
it('should handle different error types correctly', () => {
|
||||
// Test the error handling patterns used in the handlers
|
||||
const errorTypes = [
|
||||
new Error('Standard error'),
|
||||
'String error',
|
||||
{ message: 'Object error' },
|
||||
null,
|
||||
undefined
|
||||
];
|
||||
|
||||
errorTypes.forEach(error => {
|
||||
// This simulates the error handling in handlers
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
|
||||
if (error instanceof Error) {
|
||||
expect(errorMessage).toBe(error.message);
|
||||
} else {
|
||||
expect(errorMessage).toBe('Unknown error occurred');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle error objects without message property', () => {
|
||||
const errorLikeObject = { code: 500, details: 'Some details' };
|
||||
|
||||
// This simulates error handling for non-Error objects
|
||||
const errorMessage = errorLikeObject instanceof Error ?
|
||||
errorLikeObject.message : 'Unknown error occurred';
|
||||
|
||||
expect(errorMessage).toBe('Unknown error occurred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configuration Fallbacks', () => {
|
||||
it('should handle null config scenarios', () => {
|
||||
// Test configuration fallback logic
|
||||
const config = null;
|
||||
const apiConfigured = config !== null;
|
||||
|
||||
expect(apiConfigured).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle undefined config values', () => {
|
||||
const contextWithUndefined = {
|
||||
n8nApiUrl: 'https://api.n8n.cloud',
|
||||
n8nApiKey: 'test-key',
|
||||
n8nApiTimeout: undefined,
|
||||
n8nApiMaxRetries: undefined
|
||||
};
|
||||
|
||||
// Test default value assignment using nullish coalescing
|
||||
const timeout = contextWithUndefined.n8nApiTimeout ?? 30000;
|
||||
const maxRetries = contextWithUndefined.n8nApiMaxRetries ?? 3;
|
||||
|
||||
expect(timeout).toBe(30000);
|
||||
expect(maxRetries).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Array and Object Handling', () => {
|
||||
it('should handle undefined array lengths', () => {
|
||||
const workflowData: { nodes?: any[] } = {
|
||||
nodes: undefined
|
||||
};
|
||||
|
||||
// This simulates the nodeCount calculation in list workflows
|
||||
const nodeCount = workflowData.nodes?.length || 0;
|
||||
|
||||
expect(nodeCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle empty arrays', () => {
|
||||
const workflowData = {
|
||||
nodes: []
|
||||
};
|
||||
|
||||
const nodeCount = workflowData.nodes?.length || 0;
|
||||
|
||||
expect(nodeCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle arrays with elements', () => {
|
||||
const workflowData = {
|
||||
nodes: [{ id: 'node1' }, { id: 'node2' }]
|
||||
};
|
||||
|
||||
const nodeCount = workflowData.nodes?.length || 0;
|
||||
|
||||
expect(nodeCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Conditional Logic Coverage', () => {
|
||||
it('should handle truthy cursor values', () => {
|
||||
const response = {
|
||||
nextCursor: 'abc123'
|
||||
};
|
||||
|
||||
// This simulates the cursor handling logic
|
||||
const hasMore = !!response.nextCursor;
|
||||
const noteCondition = response.nextCursor ? {
|
||||
_note: "More workflows available. Use cursor to get next page."
|
||||
} : {};
|
||||
|
||||
expect(hasMore).toBe(true);
|
||||
expect(noteCondition._note).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle falsy cursor values', () => {
|
||||
const response = {
|
||||
nextCursor: null
|
||||
};
|
||||
|
||||
const hasMore = !!response.nextCursor;
|
||||
const noteCondition = response.nextCursor ? {
|
||||
_note: "More workflows available. Use cursor to get next page."
|
||||
} : {};
|
||||
|
||||
expect(hasMore).toBe(false);
|
||||
expect(noteCondition._note).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('String Manipulation', () => {
|
||||
it('should handle environment variable filtering', () => {
|
||||
const envKeys = [
|
||||
'N8N_API_URL',
|
||||
'N8N_API_KEY',
|
||||
'MCP_MODE',
|
||||
'NODE_ENV',
|
||||
'PATH',
|
||||
'HOME',
|
||||
'N8N_CUSTOM_VAR'
|
||||
];
|
||||
|
||||
// This simulates the environment variable filtering in diagnostic
|
||||
const filtered = envKeys.filter(key =>
|
||||
key.startsWith('N8N_') || key.startsWith('MCP_')
|
||||
);
|
||||
|
||||
expect(filtered).toEqual(['N8N_API_URL', 'N8N_API_KEY', 'MCP_MODE', 'N8N_CUSTOM_VAR']);
|
||||
});
|
||||
|
||||
it('should handle version string extraction', () => {
|
||||
const packageJson = {
|
||||
dependencies: {
|
||||
n8n: '^1.111.0'
|
||||
}
|
||||
};
|
||||
|
||||
// This simulates the version extraction logic
|
||||
const supportedVersion = packageJson.dependencies?.n8n?.replace(/[^0-9.]/g, '') || '';
|
||||
|
||||
expect(supportedVersion).toBe('1.111.0');
|
||||
});
|
||||
|
||||
it('should handle missing dependencies', () => {
|
||||
const packageJson: { dependencies?: { n8n?: string } } = {};
|
||||
|
||||
const supportedVersion = packageJson.dependencies?.n8n?.replace(/[^0-9.]/g, '') || '';
|
||||
|
||||
expect(supportedVersion).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,9 +17,13 @@ vi.mock('@/services/workflow-diff-engine');
|
||||
vi.mock('@/services/n8n-api-client');
|
||||
vi.mock('@/config/n8n-api');
|
||||
vi.mock('@/utils/logger');
|
||||
vi.mock('@/mcp/handlers-n8n-manager', () => ({
|
||||
getN8nApiClient: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/mcp/handlers-n8n-manager', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/mcp/handlers-n8n-manager')>();
|
||||
return {
|
||||
...actual,
|
||||
getN8nApiClient: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Import mocked modules
|
||||
import { getN8nApiClient } from '@/mcp/handlers-n8n-manager';
|
||||
|
||||
@@ -1,752 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
validateAIAgent,
|
||||
validateChatTrigger,
|
||||
validateBasicLLMChain,
|
||||
buildReverseConnectionMap,
|
||||
getAIConnections,
|
||||
validateAISpecificNodes,
|
||||
type WorkflowNode,
|
||||
type WorkflowJson
|
||||
} from '@/services/ai-node-validator';
|
||||
|
||||
describe('AI Node Validator', () => {
|
||||
describe('buildReverseConnectionMap', () => {
|
||||
it('should build reverse connections for AI language model', () => {
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
|
||||
expect(reverseMap.get('AI Agent')).toEqual([
|
||||
{
|
||||
sourceName: 'OpenAI',
|
||||
sourceType: 'ai_languageModel',
|
||||
type: 'ai_languageModel',
|
||||
index: 0
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle multiple AI connections to same node', () => {
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
},
|
||||
'HTTP Request Tool': {
|
||||
'ai_tool': [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
|
||||
},
|
||||
'Window Buffer Memory': {
|
||||
'ai_memory': [[{ node: 'AI Agent', type: 'ai_memory', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const agentConnections = reverseMap.get('AI Agent');
|
||||
|
||||
expect(agentConnections).toHaveLength(3);
|
||||
expect(agentConnections).toContainEqual(
|
||||
expect.objectContaining({ type: 'ai_languageModel' })
|
||||
);
|
||||
expect(agentConnections).toContainEqual(
|
||||
expect.objectContaining({ type: 'ai_tool' })
|
||||
);
|
||||
expect(agentConnections).toContainEqual(
|
||||
expect.objectContaining({ type: 'ai_memory' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip empty source names', () => {
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [],
|
||||
connections: {
|
||||
'': {
|
||||
'main': [[{ node: 'Target', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
|
||||
expect(reverseMap.has('Target')).toBe(false);
|
||||
});
|
||||
|
||||
it('should skip empty target node names', () => {
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [],
|
||||
connections: {
|
||||
'Source': {
|
||||
'main': [[{ node: '', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
|
||||
expect(reverseMap.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAIConnections', () => {
|
||||
it('should filter AI connections from all incoming connections', () => {
|
||||
const reverseMap = new Map();
|
||||
reverseMap.set('AI Agent', [
|
||||
{ sourceName: 'Chat Trigger', type: 'main', index: 0 },
|
||||
{ sourceName: 'OpenAI', type: 'ai_languageModel', index: 0 },
|
||||
{ sourceName: 'HTTP Tool', type: 'ai_tool', index: 0 }
|
||||
]);
|
||||
|
||||
const aiConnections = getAIConnections('AI Agent', reverseMap);
|
||||
|
||||
expect(aiConnections).toHaveLength(2);
|
||||
expect(aiConnections).not.toContainEqual(
|
||||
expect.objectContaining({ type: 'main' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by specific AI connection type', () => {
|
||||
const reverseMap = new Map();
|
||||
reverseMap.set('AI Agent', [
|
||||
{ sourceName: 'OpenAI', type: 'ai_languageModel', index: 0 },
|
||||
{ sourceName: 'Tool1', type: 'ai_tool', index: 0 },
|
||||
{ sourceName: 'Tool2', type: 'ai_tool', index: 1 }
|
||||
]);
|
||||
|
||||
const toolConnections = getAIConnections('AI Agent', reverseMap, 'ai_tool');
|
||||
|
||||
expect(toolConnections).toHaveLength(2);
|
||||
expect(toolConnections.every(c => c.type === 'ai_tool')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty array for node with no connections', () => {
|
||||
const reverseMap = new Map();
|
||||
|
||||
const connections = getAIConnections('Unknown Node', reverseMap);
|
||||
|
||||
expect(connections).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAIAgent', () => {
|
||||
it('should error on missing language model connection', () => {
|
||||
const node: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [node],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(node, reverseMap, workflow);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
message: expect.stringContaining('language model')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept single language model connection', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: { promptType: 'auto' }
|
||||
};
|
||||
|
||||
const model: WorkflowNode = {
|
||||
id: 'llm1',
|
||||
name: 'OpenAI',
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
position: [0, -100],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent, model],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(agent, reverseMap, workflow);
|
||||
|
||||
const languageModelErrors = issues.filter(i =>
|
||||
i.severity === 'error' && i.message.includes('language model')
|
||||
);
|
||||
expect(languageModelErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept dual language model connection for fallback', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: { promptType: 'auto' },
|
||||
typeVersion: 1.7
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent],
|
||||
connections: {
|
||||
'OpenAI GPT-4': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
},
|
||||
'OpenAI GPT-3.5': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 1 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(agent, reverseMap, workflow);
|
||||
|
||||
const excessModelErrors = issues.filter(i =>
|
||||
i.severity === 'error' && i.message.includes('more than 2')
|
||||
);
|
||||
expect(excessModelErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should error on more than 2 language model connections', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent],
|
||||
connections: {
|
||||
'Model1': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
},
|
||||
'Model2': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 1 }]]
|
||||
},
|
||||
'Model3': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 2 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(agent, reverseMap, workflow);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
code: 'TOO_MANY_LANGUAGE_MODELS'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should error on streaming mode with main output connections', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
promptType: 'auto',
|
||||
options: { streamResponse: true }
|
||||
}
|
||||
};
|
||||
|
||||
const responseNode: WorkflowNode = {
|
||||
id: 'response1',
|
||||
name: 'Response Node',
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
position: [200, 0],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent, responseNode],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
},
|
||||
'AI Agent': {
|
||||
'main': [[{ node: 'Response Node', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(agent, reverseMap, workflow);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
code: 'STREAMING_WITH_MAIN_OUTPUT'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should error on missing prompt text for define promptType', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
promptType: 'define'
|
||||
}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(agent, reverseMap, workflow);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
code: 'MISSING_PROMPT_TEXT'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should info on short systemMessage', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
promptType: 'auto',
|
||||
systemMessage: 'Help user' // Too short (< 20 chars)
|
||||
}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(agent, reverseMap, workflow);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'info',
|
||||
message: expect.stringContaining('systemMessage is very short')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should error on multiple memory connections', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: { promptType: 'auto' }
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
},
|
||||
'Memory1': {
|
||||
'ai_memory': [[{ node: 'AI Agent', type: 'ai_memory', index: 0 }]]
|
||||
},
|
||||
'Memory2': {
|
||||
'ai_memory': [[{ node: 'AI Agent', type: 'ai_memory', index: 1 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(agent, reverseMap, workflow);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
code: 'MULTIPLE_MEMORY_CONNECTIONS'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn on high maxIterations', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
promptType: 'auto',
|
||||
maxIterations: 60 // Exceeds threshold of 50
|
||||
}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(agent, reverseMap, workflow);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warning',
|
||||
message: expect.stringContaining('maxIterations')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate output parser with hasOutputParser flag', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
promptType: 'auto',
|
||||
hasOutputParser: true
|
||||
}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(agent, reverseMap, workflow);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
message: expect.stringContaining('output parser')
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateChatTrigger', () => {
|
||||
it('should error on streaming mode to non-AI-Agent target', () => {
|
||||
const trigger: WorkflowNode = {
|
||||
id: 'chat1',
|
||||
name: 'Chat Trigger',
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
options: { responseMode: 'streaming' }
|
||||
}
|
||||
};
|
||||
|
||||
const codeNode: WorkflowNode = {
|
||||
id: 'code1',
|
||||
name: 'Code',
|
||||
type: 'n8n-nodes-base.code',
|
||||
position: [200, 0],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [trigger, codeNode],
|
||||
connections: {
|
||||
'Chat Trigger': {
|
||||
'main': [[{ node: 'Code', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateChatTrigger(trigger, workflow, reverseMap);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
code: 'STREAMING_WRONG_TARGET'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass valid Chat Trigger with streaming to AI Agent', () => {
|
||||
const trigger: WorkflowNode = {
|
||||
id: 'chat1',
|
||||
name: 'Chat Trigger',
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
options: { responseMode: 'streaming' }
|
||||
}
|
||||
};
|
||||
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [200, 0],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [trigger, agent],
|
||||
connections: {
|
||||
'Chat Trigger': {
|
||||
'main': [[{ node: 'AI Agent', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateChatTrigger(trigger, workflow, reverseMap);
|
||||
|
||||
const errors = issues.filter(i => i.severity === 'error');
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should error on missing outgoing connections', () => {
|
||||
const trigger: WorkflowNode = {
|
||||
id: 'chat1',
|
||||
name: 'Chat Trigger',
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [trigger],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateChatTrigger(trigger, workflow, reverseMap);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
code: 'MISSING_CONNECTIONS'
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBasicLLMChain', () => {
|
||||
it('should error on missing language model connection', () => {
|
||||
const chain: WorkflowNode = {
|
||||
id: 'chain1',
|
||||
name: 'LLM Chain',
|
||||
type: '@n8n/n8n-nodes-langchain.chainLlm',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [chain],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateBasicLLMChain(chain, reverseMap);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
message: expect.stringContaining('language model')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass valid LLM Chain', () => {
|
||||
const chain: WorkflowNode = {
|
||||
id: 'chain1',
|
||||
name: 'LLM Chain',
|
||||
type: '@n8n/n8n-nodes-langchain.chainLlm',
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
prompt: 'Summarize the following text: {{$json.text}}'
|
||||
}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [chain],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'LLM Chain', type: 'ai_languageModel', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateBasicLLMChain(chain, reverseMap);
|
||||
|
||||
const errors = issues.filter(i => i.severity === 'error');
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAISpecificNodes', () => {
|
||||
it('should validate complete AI Agent workflow', () => {
|
||||
const chatTrigger: WorkflowNode = {
|
||||
id: 'chat1',
|
||||
name: 'Chat Trigger',
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [200, 0],
|
||||
parameters: {
|
||||
promptType: 'auto'
|
||||
}
|
||||
};
|
||||
|
||||
const model: WorkflowNode = {
|
||||
id: 'llm1',
|
||||
name: 'OpenAI',
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
position: [200, -100],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const httpTool: WorkflowNode = {
|
||||
id: 'tool1',
|
||||
name: 'Weather API',
|
||||
type: '@n8n/n8n-nodes-langchain.toolHttpRequest',
|
||||
position: [200, 100],
|
||||
parameters: {
|
||||
toolDescription: 'Get current weather for a city',
|
||||
method: 'GET',
|
||||
url: 'https://api.weather.com/v1/current?city={city}',
|
||||
placeholderDefinitions: {
|
||||
values: [
|
||||
{ name: 'city', description: 'City name' }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [chatTrigger, agent, model, httpTool],
|
||||
connections: {
|
||||
'Chat Trigger': {
|
||||
'main': [[{ node: 'AI Agent', type: 'main', index: 0 }]]
|
||||
},
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
},
|
||||
'Weather API': {
|
||||
'ai_tool': [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const issues = validateAISpecificNodes(workflow);
|
||||
|
||||
const errors = issues.filter(i => i.severity === 'error');
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect missing language model in workflow', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const issues = validateAISpecificNodes(workflow);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
message: expect.stringContaining('language model')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate all AI tool sub-nodes in workflow', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: { promptType: 'auto' }
|
||||
};
|
||||
|
||||
const invalidTool: WorkflowNode = {
|
||||
id: 'tool1',
|
||||
name: 'Bad Tool',
|
||||
type: '@n8n/n8n-nodes-langchain.toolHttpRequest',
|
||||
position: [0, 100],
|
||||
parameters: {} // Missing toolDescription and url
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent, invalidTool],
|
||||
connections: {
|
||||
'Model': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
},
|
||||
'Bad Tool': {
|
||||
'ai_tool': [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const issues = validateAISpecificNodes(workflow);
|
||||
|
||||
// Should have errors from missing toolDescription and url
|
||||
expect(issues.filter(i => i.severity === 'error').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
validateAIAgent,
|
||||
validateChatTrigger,
|
||||
validateBasicLLMChain,
|
||||
buildReverseConnectionMap,
|
||||
getAIConnections,
|
||||
validateAISpecificNodes,
|
||||
type WorkflowNode,
|
||||
type WorkflowJson
|
||||
} from '@/services/ai-node-validator';
|
||||
import {
|
||||
validateHTTPRequestTool,
|
||||
validateCodeTool,
|
||||
@@ -12,9 +22,748 @@ import {
|
||||
validateWikipediaTool,
|
||||
validateSearXngTool,
|
||||
validateWolframAlphaTool,
|
||||
type WorkflowNode
|
||||
} from '@/services/ai-tool-validators';
|
||||
|
||||
describe('AI Node Validator', () => {
|
||||
describe('buildReverseConnectionMap', () => {
|
||||
it('should build reverse connections for AI language model', () => {
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
|
||||
expect(reverseMap.get('AI Agent')).toEqual([
|
||||
{
|
||||
sourceName: 'OpenAI',
|
||||
sourceType: 'ai_languageModel',
|
||||
type: 'ai_languageModel',
|
||||
index: 0
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle multiple AI connections to same node', () => {
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
},
|
||||
'HTTP Request Tool': {
|
||||
'ai_tool': [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
|
||||
},
|
||||
'Window Buffer Memory': {
|
||||
'ai_memory': [[{ node: 'AI Agent', type: 'ai_memory', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const agentConnections = reverseMap.get('AI Agent');
|
||||
|
||||
expect(agentConnections).toHaveLength(3);
|
||||
expect(agentConnections).toContainEqual(
|
||||
expect.objectContaining({ type: 'ai_languageModel' })
|
||||
);
|
||||
expect(agentConnections).toContainEqual(
|
||||
expect.objectContaining({ type: 'ai_tool' })
|
||||
);
|
||||
expect(agentConnections).toContainEqual(
|
||||
expect.objectContaining({ type: 'ai_memory' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip empty source names', () => {
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [],
|
||||
connections: {
|
||||
'': {
|
||||
'main': [[{ node: 'Target', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
|
||||
expect(reverseMap.has('Target')).toBe(false);
|
||||
});
|
||||
|
||||
it('should skip empty target node names', () => {
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [],
|
||||
connections: {
|
||||
'Source': {
|
||||
'main': [[{ node: '', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
|
||||
expect(reverseMap.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAIConnections', () => {
|
||||
it('should filter AI connections from all incoming connections', () => {
|
||||
const reverseMap = new Map();
|
||||
reverseMap.set('AI Agent', [
|
||||
{ sourceName: 'Chat Trigger', type: 'main', index: 0 },
|
||||
{ sourceName: 'OpenAI', type: 'ai_languageModel', index: 0 },
|
||||
{ sourceName: 'HTTP Tool', type: 'ai_tool', index: 0 }
|
||||
]);
|
||||
|
||||
const aiConnections = getAIConnections('AI Agent', reverseMap);
|
||||
|
||||
expect(aiConnections).toHaveLength(2);
|
||||
expect(aiConnections).not.toContainEqual(
|
||||
expect.objectContaining({ type: 'main' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by specific AI connection type', () => {
|
||||
const reverseMap = new Map();
|
||||
reverseMap.set('AI Agent', [
|
||||
{ sourceName: 'OpenAI', type: 'ai_languageModel', index: 0 },
|
||||
{ sourceName: 'Tool1', type: 'ai_tool', index: 0 },
|
||||
{ sourceName: 'Tool2', type: 'ai_tool', index: 1 }
|
||||
]);
|
||||
|
||||
const toolConnections = getAIConnections('AI Agent', reverseMap, 'ai_tool');
|
||||
|
||||
expect(toolConnections).toHaveLength(2);
|
||||
expect(toolConnections.every(c => c.type === 'ai_tool')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty array for node with no connections', () => {
|
||||
const reverseMap = new Map();
|
||||
|
||||
const connections = getAIConnections('Unknown Node', reverseMap);
|
||||
|
||||
expect(connections).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAIAgent', () => {
|
||||
it('should error on missing language model connection', () => {
|
||||
const node: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [node],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(node, reverseMap, workflow);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
message: expect.stringContaining('language model')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept single language model connection', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: { promptType: 'auto' }
|
||||
};
|
||||
|
||||
const model: WorkflowNode = {
|
||||
id: 'llm1',
|
||||
name: 'OpenAI',
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
position: [0, -100],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent, model],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(agent, reverseMap, workflow);
|
||||
|
||||
const languageModelErrors = issues.filter(i =>
|
||||
i.severity === 'error' && i.message.includes('language model')
|
||||
);
|
||||
expect(languageModelErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept dual language model connection for fallback', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: { promptType: 'auto' },
|
||||
typeVersion: 1.7
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent],
|
||||
connections: {
|
||||
'OpenAI GPT-4': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
},
|
||||
'OpenAI GPT-3.5': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 1 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(agent, reverseMap, workflow);
|
||||
|
||||
const excessModelErrors = issues.filter(i =>
|
||||
i.severity === 'error' && i.message.includes('more than 2')
|
||||
);
|
||||
expect(excessModelErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should error on more than 2 language model connections', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent],
|
||||
connections: {
|
||||
'Model1': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
},
|
||||
'Model2': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 1 }]]
|
||||
},
|
||||
'Model3': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 2 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(agent, reverseMap, workflow);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
code: 'TOO_MANY_LANGUAGE_MODELS'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should error on streaming mode with main output connections', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
promptType: 'auto',
|
||||
options: { streamResponse: true }
|
||||
}
|
||||
};
|
||||
|
||||
const responseNode: WorkflowNode = {
|
||||
id: 'response1',
|
||||
name: 'Response Node',
|
||||
type: 'n8n-nodes-base.respondToWebhook',
|
||||
position: [200, 0],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent, responseNode],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
},
|
||||
'AI Agent': {
|
||||
'main': [[{ node: 'Response Node', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(agent, reverseMap, workflow);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
code: 'STREAMING_WITH_MAIN_OUTPUT'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should error on missing prompt text for define promptType', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
promptType: 'define'
|
||||
}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(agent, reverseMap, workflow);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
code: 'MISSING_PROMPT_TEXT'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should info on short systemMessage', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
promptType: 'auto',
|
||||
systemMessage: 'Help user'
|
||||
}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(agent, reverseMap, workflow);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'info',
|
||||
message: expect.stringContaining('systemMessage is very short')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should error on multiple memory connections', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: { promptType: 'auto' }
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
},
|
||||
'Memory1': {
|
||||
'ai_memory': [[{ node: 'AI Agent', type: 'ai_memory', index: 0 }]]
|
||||
},
|
||||
'Memory2': {
|
||||
'ai_memory': [[{ node: 'AI Agent', type: 'ai_memory', index: 1 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(agent, reverseMap, workflow);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
code: 'MULTIPLE_MEMORY_CONNECTIONS'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should warn on high maxIterations', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
promptType: 'auto',
|
||||
maxIterations: 60
|
||||
}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(agent, reverseMap, workflow);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'warning',
|
||||
message: expect.stringContaining('maxIterations')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate output parser with hasOutputParser flag', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
promptType: 'auto',
|
||||
hasOutputParser: true
|
||||
}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateAIAgent(agent, reverseMap, workflow);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
message: expect.stringContaining('output parser')
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateChatTrigger', () => {
|
||||
it('should error on streaming mode to non-AI-Agent target', () => {
|
||||
const trigger: WorkflowNode = {
|
||||
id: 'chat1',
|
||||
name: 'Chat Trigger',
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
options: { responseMode: 'streaming' }
|
||||
}
|
||||
};
|
||||
|
||||
const codeNode: WorkflowNode = {
|
||||
id: 'code1',
|
||||
name: 'Code',
|
||||
type: 'n8n-nodes-base.code',
|
||||
position: [200, 0],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [trigger, codeNode],
|
||||
connections: {
|
||||
'Chat Trigger': {
|
||||
'main': [[{ node: 'Code', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateChatTrigger(trigger, workflow, reverseMap);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
code: 'STREAMING_WRONG_TARGET'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass valid Chat Trigger with streaming to AI Agent', () => {
|
||||
const trigger: WorkflowNode = {
|
||||
id: 'chat1',
|
||||
name: 'Chat Trigger',
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
options: { responseMode: 'streaming' }
|
||||
}
|
||||
};
|
||||
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [200, 0],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [trigger, agent],
|
||||
connections: {
|
||||
'Chat Trigger': {
|
||||
'main': [[{ node: 'AI Agent', type: 'main', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateChatTrigger(trigger, workflow, reverseMap);
|
||||
|
||||
const errors = issues.filter(i => i.severity === 'error');
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should error on missing outgoing connections', () => {
|
||||
const trigger: WorkflowNode = {
|
||||
id: 'chat1',
|
||||
name: 'Chat Trigger',
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [trigger],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateChatTrigger(trigger, workflow, reverseMap);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
code: 'MISSING_CONNECTIONS'
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBasicLLMChain', () => {
|
||||
it('should error on missing language model connection', () => {
|
||||
const chain: WorkflowNode = {
|
||||
id: 'chain1',
|
||||
name: 'LLM Chain',
|
||||
type: '@n8n/n8n-nodes-langchain.chainLlm',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [chain],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateBasicLLMChain(chain, reverseMap);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
message: expect.stringContaining('language model')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass valid LLM Chain', () => {
|
||||
const chain: WorkflowNode = {
|
||||
id: 'chain1',
|
||||
name: 'LLM Chain',
|
||||
type: '@n8n/n8n-nodes-langchain.chainLlm',
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
prompt: 'Summarize the following text: {{$json.text}}'
|
||||
}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [chain],
|
||||
connections: {
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'LLM Chain', type: 'ai_languageModel', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const reverseMap = buildReverseConnectionMap(workflow);
|
||||
const issues = validateBasicLLMChain(chain, reverseMap);
|
||||
|
||||
const errors = issues.filter(i => i.severity === 'error');
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAISpecificNodes', () => {
|
||||
it('should validate complete AI Agent workflow', () => {
|
||||
const chatTrigger: WorkflowNode = {
|
||||
id: 'chat1',
|
||||
name: 'Chat Trigger',
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [200, 0],
|
||||
parameters: {
|
||||
promptType: 'auto'
|
||||
}
|
||||
};
|
||||
|
||||
const model: WorkflowNode = {
|
||||
id: 'llm1',
|
||||
name: 'OpenAI',
|
||||
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
|
||||
position: [200, -100],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const httpTool: WorkflowNode = {
|
||||
id: 'tool1',
|
||||
name: 'Weather API',
|
||||
type: '@n8n/n8n-nodes-langchain.toolHttpRequest',
|
||||
position: [200, 100],
|
||||
parameters: {
|
||||
toolDescription: 'Get current weather for a city',
|
||||
method: 'GET',
|
||||
url: 'https://api.weather.com/v1/current?city={city}',
|
||||
placeholderDefinitions: {
|
||||
values: [
|
||||
{ name: 'city', description: 'City name' }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [chatTrigger, agent, model, httpTool],
|
||||
connections: {
|
||||
'Chat Trigger': {
|
||||
'main': [[{ node: 'AI Agent', type: 'main', index: 0 }]]
|
||||
},
|
||||
'OpenAI': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
},
|
||||
'Weather API': {
|
||||
'ai_tool': [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const issues = validateAISpecificNodes(workflow);
|
||||
|
||||
const errors = issues.filter(i => i.severity === 'error');
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect missing language model in workflow', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const issues = validateAISpecificNodes(workflow);
|
||||
|
||||
expect(issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
severity: 'error',
|
||||
message: expect.stringContaining('language model')
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate all AI tool sub-nodes in workflow', () => {
|
||||
const agent: WorkflowNode = {
|
||||
id: 'agent1',
|
||||
name: 'AI Agent',
|
||||
type: '@n8n/n8n-nodes-langchain.agent',
|
||||
position: [0, 0],
|
||||
parameters: { promptType: 'auto' }
|
||||
};
|
||||
|
||||
const invalidTool: WorkflowNode = {
|
||||
id: 'tool1',
|
||||
name: 'Bad Tool',
|
||||
type: '@n8n/n8n-nodes-langchain.toolHttpRequest',
|
||||
position: [0, 100],
|
||||
parameters: {}
|
||||
};
|
||||
|
||||
const workflow: WorkflowJson = {
|
||||
nodes: [agent, invalidTool],
|
||||
connections: {
|
||||
'Model': {
|
||||
'ai_languageModel': [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]]
|
||||
},
|
||||
'Bad Tool': {
|
||||
'ai_tool': [[{ node: 'AI Agent', type: 'ai_tool', index: 0 }]]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const issues = validateAISpecificNodes(workflow);
|
||||
|
||||
expect(issues.filter(i => i.severity === 'error').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI Tool Validators', () => {
|
||||
describe('validateHTTPRequestTool', () => {
|
||||
it('should error on missing toolDescription', () => {
|
||||
@@ -48,7 +797,7 @@ describe('AI Tool Validators', () => {
|
||||
parameters: {
|
||||
method: 'GET',
|
||||
url: 'https://api.weather.com/data',
|
||||
toolDescription: 'Weather' // Too short (7 chars, need 15)
|
||||
toolDescription: 'Weather'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -120,7 +869,6 @@ describe('AI Tool Validators', () => {
|
||||
|
||||
const issues = validateHTTPRequestTool(node);
|
||||
|
||||
// Should not error on URL format when it contains expressions
|
||||
const urlErrors = issues.filter(i => i.code === 'INVALID_URL_FORMAT');
|
||||
expect(urlErrors).toHaveLength(0);
|
||||
});
|
||||
@@ -194,7 +942,6 @@ describe('AI Tool Validators', () => {
|
||||
|
||||
const issues = validateHTTPRequestTool(node);
|
||||
|
||||
// Should have no errors
|
||||
const errors = issues.filter(i => i.severity === 'error');
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
@@ -327,7 +1074,7 @@ return { cost: cost.toFixed(2) };`,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
toolDescription: 'Search through product documentation',
|
||||
topK: 25 // Exceeds threshold of 20
|
||||
topK: 25
|
||||
}
|
||||
};
|
||||
|
||||
@@ -456,7 +1203,7 @@ return { cost: cost.toFixed(2) };`,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
toolDescription: 'Performs complex research tasks',
|
||||
maxIterations: 60 // Exceeds threshold of 50
|
||||
maxIterations: 60
|
||||
}
|
||||
};
|
||||
|
||||
@@ -565,7 +1312,6 @@ return { cost: cost.toFixed(2) };`,
|
||||
|
||||
const issues = validateCalculatorTool(node);
|
||||
|
||||
// Calculator Tool has built-in description, no validation needed
|
||||
expect(issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -599,7 +1345,6 @@ return { cost: cost.toFixed(2) };`,
|
||||
|
||||
const issues = validateThinkTool(node);
|
||||
|
||||
// Think Tool has built-in description, no validation needed
|
||||
expect(issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -1,879 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ConfigValidator } from '@/services/config-validator';
|
||||
import type { ValidationResult, ValidationError, ValidationWarning } from '@/services/config-validator';
|
||||
|
||||
// Mock the database
|
||||
vi.mock('better-sqlite3');
|
||||
|
||||
describe('ConfigValidator - Basic Validation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('validate', () => {
|
||||
it('should validate required fields for Slack message post', () => {
|
||||
const nodeType = 'nodes-base.slack';
|
||||
const config = {
|
||||
resource: 'message',
|
||||
operation: 'post'
|
||||
// Missing required 'channel' field
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
required: true,
|
||||
default: 'message',
|
||||
options: [
|
||||
{ name: 'Message', value: 'message' },
|
||||
{ name: 'Channel', value: 'channel' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
required: true,
|
||||
default: 'post',
|
||||
displayOptions: {
|
||||
show: { resource: ['message'] }
|
||||
},
|
||||
options: [
|
||||
{ name: 'Post', value: 'post' },
|
||||
{ name: 'Update', value: 'update' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'channel',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['message'],
|
||||
operation: ['post']
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toMatchObject({
|
||||
type: 'missing_required',
|
||||
property: 'channel',
|
||||
message: "Required property 'channel' is missing",
|
||||
fix: 'Add channel to your configuration'
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate successfully with all required fields', () => {
|
||||
const nodeType = 'nodes-base.slack';
|
||||
const config = {
|
||||
resource: 'message',
|
||||
operation: 'post',
|
||||
channel: '#general',
|
||||
text: 'Hello, Slack!'
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
required: true,
|
||||
default: 'message',
|
||||
options: [
|
||||
{ name: 'Message', value: 'message' },
|
||||
{ name: 'Channel', value: 'channel' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
required: true,
|
||||
default: 'post',
|
||||
displayOptions: {
|
||||
show: { resource: ['message'] }
|
||||
},
|
||||
options: [
|
||||
{ name: 'Post', value: 'post' },
|
||||
{ name: 'Update', value: 'update' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'channel',
|
||||
type: 'string',
|
||||
required: true,
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['message'],
|
||||
operation: ['post']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
default: '',
|
||||
displayOptions: {
|
||||
show: {
|
||||
resource: ['message'],
|
||||
operation: ['post']
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle unknown node types gracefully', () => {
|
||||
const nodeType = 'nodes-base.unknown';
|
||||
const config = { field: 'value' };
|
||||
const properties: any[] = [];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
// May have warnings about unused properties
|
||||
});
|
||||
|
||||
it('should validate property types', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = {
|
||||
numberField: 'not-a-number', // Should be number
|
||||
booleanField: 'yes' // Should be boolean
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'numberField', type: 'number' },
|
||||
{ name: 'booleanField', type: 'boolean' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.errors).toHaveLength(2);
|
||||
expect(result.errors.some(e =>
|
||||
e.property === 'numberField' &&
|
||||
e.type === 'invalid_type'
|
||||
)).toBe(true);
|
||||
expect(result.errors.some(e =>
|
||||
e.property === 'booleanField' &&
|
||||
e.type === 'invalid_type'
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate option values', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = {
|
||||
selectField: 'invalid-option'
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'selectField',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Option A', value: 'a' },
|
||||
{ name: 'Option B', value: 'b' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toMatchObject({
|
||||
type: 'invalid_value',
|
||||
property: 'selectField',
|
||||
message: expect.stringContaining('Invalid value')
|
||||
});
|
||||
});
|
||||
|
||||
it('should check property visibility based on displayOptions', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = {
|
||||
resource: 'user',
|
||||
userField: 'visible'
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'User', value: 'user' },
|
||||
{ name: 'Post', value: 'post' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'userField',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: { resource: ['user'] }
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'postField',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: { resource: ['post'] }
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.visibleProperties).toContain('resource');
|
||||
expect(result.visibleProperties).toContain('userField');
|
||||
expect(result.hiddenProperties).toContain('postField');
|
||||
});
|
||||
|
||||
it('should handle empty properties array', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = { someField: 'value' };
|
||||
const properties: any[] = [];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle missing displayOptions gracefully', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = { field1: 'value1' };
|
||||
const properties = [
|
||||
{ name: 'field1', type: 'string' }
|
||||
// No displayOptions
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.visibleProperties).toContain('field1');
|
||||
});
|
||||
|
||||
it('should validate options with array format', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = { optionField: 'b' };
|
||||
const properties = [
|
||||
{
|
||||
name: 'optionField',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Option A', value: 'a' },
|
||||
{ name: 'Option B', value: 'b' },
|
||||
{ name: 'Option C', value: 'c' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases and additional coverage', () => {
|
||||
it('should handle null and undefined config values', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = {
|
||||
nullField: null,
|
||||
undefinedField: undefined,
|
||||
validField: 'value'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'nullField', type: 'string', required: true },
|
||||
{ name: 'undefinedField', type: 'string', required: true },
|
||||
{ name: 'validField', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.errors.some(e => e.property === 'nullField')).toBe(true);
|
||||
expect(result.errors.some(e => e.property === 'undefinedField')).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate nested displayOptions conditions', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = {
|
||||
mode: 'advanced',
|
||||
resource: 'user',
|
||||
advancedUserField: 'value'
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Simple', value: 'simple' },
|
||||
{ name: 'Advanced', value: 'advanced' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
displayOptions: {
|
||||
show: { mode: ['advanced'] }
|
||||
},
|
||||
options: [
|
||||
{ name: 'User', value: 'user' },
|
||||
{ name: 'Post', value: 'post' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'advancedUserField',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: {
|
||||
mode: ['advanced'],
|
||||
resource: ['user']
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.visibleProperties).toContain('advancedUserField');
|
||||
});
|
||||
|
||||
it('should handle hide conditions in displayOptions', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = {
|
||||
showAdvanced: false,
|
||||
hiddenField: 'should-not-be-here'
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'showAdvanced',
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
name: 'hiddenField',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
hide: { showAdvanced: [false] }
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.hiddenProperties).toContain('hiddenField');
|
||||
expect(result.warnings.some(w =>
|
||||
w.property === 'hiddenField' &&
|
||||
w.type === 'inefficient'
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle internal properties that start with underscore', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = {
|
||||
'@version': 1,
|
||||
'_internalField': 'value',
|
||||
normalField: 'value'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'normalField', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
// Should not warn about @version or _internalField
|
||||
expect(result.warnings.some(w =>
|
||||
w.property === '@version' ||
|
||||
w.property === '_internalField'
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('should warn about inefficient configured but hidden properties', () => {
|
||||
const nodeType = 'nodes-base.test'; // Changed from Code node
|
||||
const config = {
|
||||
mode: 'manual',
|
||||
automaticField: 'This will not be used'
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'mode',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Manual', value: 'manual' },
|
||||
{ name: 'Automatic', value: 'automatic' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'automaticField',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: { mode: ['automatic'] }
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.warnings.some(w =>
|
||||
w.type === 'inefficient' &&
|
||||
w.property === 'automaticField' &&
|
||||
w.message.includes("won't be used")
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should suggest commonly used properties', () => {
|
||||
const nodeType = 'nodes-base.httpRequest';
|
||||
const config = {
|
||||
method: 'GET',
|
||||
url: 'https://api.example.com/data'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'method', type: 'options' },
|
||||
{ name: 'url', type: 'string' },
|
||||
{ name: 'headers', type: 'json' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
// Common properties suggestion not implemented for headers
|
||||
expect(result.suggestions.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resourceLocator validation', () => {
|
||||
it('should reject string value when resourceLocator object is required', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: 'gpt-4o-mini' // Wrong - should be object with mode and value
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
displayName: 'Model',
|
||||
type: 'resourceLocator',
|
||||
required: true,
|
||||
default: { mode: 'list', value: 'gpt-4o-mini' }
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toMatchObject({
|
||||
type: 'invalid_type',
|
||||
property: 'model',
|
||||
message: expect.stringContaining('must be an object with \'mode\' and \'value\' properties')
|
||||
});
|
||||
expect(result.errors[0].fix).toContain('mode');
|
||||
expect(result.errors[0].fix).toContain('value');
|
||||
});
|
||||
|
||||
it('should accept valid resourceLocator with mode and value', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'list',
|
||||
value: 'gpt-4o-mini'
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
displayName: 'Model',
|
||||
type: 'resourceLocator',
|
||||
required: true,
|
||||
default: { mode: 'list', value: 'gpt-4o-mini' }
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject null value for resourceLocator', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: null
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e =>
|
||||
e.property === 'model' &&
|
||||
e.type === 'invalid_type'
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject array value for resourceLocator', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: ['gpt-4o-mini']
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e =>
|
||||
e.property === 'model' &&
|
||||
e.type === 'invalid_type' &&
|
||||
e.message.includes('must be an object')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect missing mode property in resourceLocator', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
value: 'gpt-4o-mini'
|
||||
// Missing mode property
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e =>
|
||||
e.property === 'model.mode' &&
|
||||
e.type === 'missing_required' &&
|
||||
e.message.includes('missing required property \'mode\'')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect missing value property in resourceLocator', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'list'
|
||||
// Missing value property
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
displayName: 'Model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e =>
|
||||
e.property === 'model.value' &&
|
||||
e.type === 'missing_required' &&
|
||||
e.message.includes('missing required property \'value\'')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect invalid mode type in resourceLocator', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 123, // Should be string
|
||||
value: 'gpt-4o-mini'
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e =>
|
||||
e.property === 'model.mode' &&
|
||||
e.type === 'invalid_type' &&
|
||||
e.message.includes('must be a string')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept resourceLocator with mode "id"', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'id',
|
||||
value: 'gpt-4o-2024-11-20'
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject number value when resourceLocator is required', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: 12345 // Wrong type
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0].type).toBe('invalid_type');
|
||||
expect(result.errors[0].message).toContain('must be an object');
|
||||
});
|
||||
|
||||
it('should provide helpful fix suggestion for string to resourceLocator conversion', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: 'gpt-4o-mini'
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.errors[0].fix).toContain('{ mode: "list", value: "gpt-4o-mini" }');
|
||||
expect(result.errors[0].fix).toContain('{ mode: "id", value: "gpt-4o-mini" }');
|
||||
});
|
||||
|
||||
it('should reject invalid mode values when schema defines allowed modes', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'invalid-mode',
|
||||
value: 'gpt-4o-mini'
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true,
|
||||
// In real n8n, modes are at top level, not in typeOptions
|
||||
modes: [
|
||||
{ name: 'list', displayName: 'List' },
|
||||
{ name: 'id', displayName: 'ID' },
|
||||
{ name: 'url', displayName: 'URL' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e =>
|
||||
e.property === 'model.mode' &&
|
||||
e.type === 'invalid_value' &&
|
||||
e.message.includes('must be one of [list, id, url]')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle modes defined as array format', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'custom',
|
||||
value: 'gpt-4o-mini'
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true,
|
||||
// Array format at top level (real n8n structure)
|
||||
modes: [
|
||||
{ name: 'list', displayName: 'List' },
|
||||
{ name: 'id', displayName: 'ID' },
|
||||
{ name: 'custom', displayName: 'Custom' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle malformed modes schema gracefully', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'any-mode',
|
||||
value: 'gpt-4o-mini'
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true,
|
||||
modes: 'invalid-string' // Malformed schema at top level
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
// Should NOT crash, should skip validation
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.some(e => e.property === 'model.mode')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty modes definition gracefully', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'any-mode',
|
||||
value: 'gpt-4o-mini'
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true,
|
||||
modes: {} // Empty object at top level
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
// Should skip validation with empty modes
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.some(e => e.property === 'model.mode')).toBe(false);
|
||||
});
|
||||
|
||||
it('should skip mode validation when modes not provided', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'custom-mode',
|
||||
value: 'gpt-4o-mini'
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
// No modes property - schema doesn't define modes
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
// Should accept any mode when schema doesn't define them
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept resourceLocator with mode "url"', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'url',
|
||||
value: 'https://api.example.com/models/custom'
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect empty resourceLocator object', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {} // Empty object, missing both mode and value
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThanOrEqual(2); // Both mode and value missing
|
||||
expect(result.errors.some(e => e.property === 'model.mode')).toBe(true);
|
||||
expect(result.errors.some(e => e.property === 'model.value')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle resourceLocator with extra properties gracefully', () => {
|
||||
const nodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
const config = {
|
||||
model: {
|
||||
mode: 'list',
|
||||
value: 'gpt-4o-mini',
|
||||
extraProperty: 'ignored' // Extra properties should be ignored
|
||||
}
|
||||
};
|
||||
const properties = [
|
||||
{
|
||||
name: 'model',
|
||||
type: 'resourceLocator',
|
||||
required: true
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(true); // Should pass with extra properties
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,524 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ConfigValidator } from '../../../src/services/config-validator';
|
||||
|
||||
describe('ConfigValidator _cnd operators', () => {
|
||||
describe('isPropertyVisible with _cnd operators', () => {
|
||||
describe('eq operator', () => {
|
||||
it('should match when values are equal', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { status: [{ _cnd: { eq: 'active' } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { status: 'active' })).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when values are not equal', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { status: [{ _cnd: { eq: 'active' } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { status: 'inactive' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should match numeric equality', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { '@version': [{ _cnd: { eq: 1 } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1 })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('not operator', () => {
|
||||
it('should match when values are not equal', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { status: [{ _cnd: { not: 'disabled' } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { status: 'active' })).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when values are equal', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { status: [{ _cnd: { not: 'disabled' } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { status: 'disabled' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gte operator (greater than or equal)', () => {
|
||||
it('should match when value is greater', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { '@version': [{ _cnd: { gte: 1.1 } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.0 })).toBe(true);
|
||||
});
|
||||
|
||||
it('should match when value is equal', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { '@version': [{ _cnd: { gte: 1.1 } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.1 })).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when value is less', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { '@version': [{ _cnd: { gte: 1.1 } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.0 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lte operator (less than or equal)', () => {
|
||||
it('should match when value is less', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { '@version': [{ _cnd: { lte: 2.0 } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.5 })).toBe(true);
|
||||
});
|
||||
|
||||
it('should match when value is equal', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { '@version': [{ _cnd: { lte: 2.0 } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.0 })).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when value is greater', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { '@version': [{ _cnd: { lte: 2.0 } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.5 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gt operator (greater than)', () => {
|
||||
it('should match when value is greater', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { count: [{ _cnd: { gt: 5 } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { count: 10 })).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when value is equal', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { count: [{ _cnd: { gt: 5 } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { count: 5 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lt operator (less than)', () => {
|
||||
it('should match when value is less', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { count: [{ _cnd: { lt: 10 } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { count: 5 })).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when value is equal', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { count: [{ _cnd: { lt: 10 } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { count: 10 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('between operator', () => {
|
||||
it('should match when value is within range', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { '@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 4.3 })).toBe(true);
|
||||
});
|
||||
|
||||
it('should match when value equals lower bound', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { '@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 4 })).toBe(true);
|
||||
});
|
||||
|
||||
it('should match when value equals upper bound', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { '@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 4.6 })).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when value is below range', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { '@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 3.9 })).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match when value is above range', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { '@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 5 })).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match when between structure is null', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { '@version': [{ _cnd: { between: null } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 4 })).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match when between is missing from field', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { '@version': [{ _cnd: { between: { to: 5 } } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 4 })).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match when between is missing to field', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { '@version': [{ _cnd: { between: { from: 3 } } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 4 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startsWith operator', () => {
|
||||
it('should match when string starts with prefix', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { name: [{ _cnd: { startsWith: 'test' } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { name: 'testUser' })).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when string does not start with prefix', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { name: [{ _cnd: { startsWith: 'test' } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { name: 'mytest' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match non-string values', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { value: [{ _cnd: { startsWith: 'test' } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { value: 123 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('endsWith operator', () => {
|
||||
it('should match when string ends with suffix', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { email: [{ _cnd: { endsWith: '@example.com' } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { email: 'user@example.com' })).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when string does not end with suffix', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { email: [{ _cnd: { endsWith: '@example.com' } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { email: 'user@other.com' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('includes operator', () => {
|
||||
it('should match when string contains substring', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { eventId: [{ _cnd: { includes: '_' } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { eventId: 'event_123' })).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when string does not contain substring', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { eventId: [{ _cnd: { includes: '_' } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { eventId: 'event123' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('regex operator', () => {
|
||||
it('should match when string matches regex pattern', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { id: [{ _cnd: { regex: '^[A-Z]{3}\\d{4}$' } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { id: 'ABC1234' })).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when string does not match regex pattern', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { id: [{ _cnd: { regex: '^[A-Z]{3}\\d{4}$' } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { id: 'abc1234' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match when regex pattern is invalid', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { id: [{ _cnd: { regex: '[invalid(regex' } }] }
|
||||
}
|
||||
};
|
||||
// Invalid regex should return false without throwing
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { id: 'test' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match non-string values', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { value: [{ _cnd: { regex: '\\d+' } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { value: 123 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exists operator', () => {
|
||||
it('should match when field exists and is not null', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { optionalField: [{ _cnd: { exists: true } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { optionalField: 'value' })).toBe(true);
|
||||
});
|
||||
|
||||
it('should match when field exists with value 0', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { optionalField: [{ _cnd: { exists: true } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { optionalField: 0 })).toBe(true);
|
||||
});
|
||||
|
||||
it('should match when field exists with empty string', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { optionalField: [{ _cnd: { exists: true } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { optionalField: '' })).toBe(true);
|
||||
});
|
||||
|
||||
it('should not match when field is undefined', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { optionalField: [{ _cnd: { exists: true } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { otherField: 'value' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should not match when field is null', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { optionalField: [{ _cnd: { exists: true } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { optionalField: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mixed plain values and _cnd conditions', () => {
|
||||
it('should match plain value in array with _cnd', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { status: ['active', { _cnd: { eq: 'pending' } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { status: 'active' })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { status: 'pending' })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { status: 'disabled' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle multiple conditions with AND logic', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: {
|
||||
'@version': [{ _cnd: { gte: 1.1 } }],
|
||||
mode: ['advanced']
|
||||
}
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.0, mode: 'advanced' })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.0, mode: 'basic' })).toBe(false);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.0, mode: 'advanced' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hide conditions with _cnd', () => {
|
||||
it('should hide property when _cnd condition matches', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
hide: { '@version': [{ _cnd: { lt: 2.0 } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.5 })).toBe(false);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.0 })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.5 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Execute Workflow Trigger scenario', () => {
|
||||
it('should show property when @version >= 1.1', () => {
|
||||
const prop = {
|
||||
name: 'inputSource',
|
||||
displayOptions: {
|
||||
show: { '@version': [{ _cnd: { gte: 1.1 } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.1 })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.2 })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.0 })).toBe(true);
|
||||
});
|
||||
|
||||
it('should hide property when @version < 1.1', () => {
|
||||
const prop = {
|
||||
name: 'inputSource',
|
||||
displayOptions: {
|
||||
show: { '@version': [{ _cnd: { gte: 1.1 } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.0 })).toBe(false);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1 })).toBe(false);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 0.9 })).toBe(false);
|
||||
});
|
||||
|
||||
it('should show outdated version warning only for v1', () => {
|
||||
const prop = {
|
||||
name: 'outdatedVersionWarning',
|
||||
displayOptions: {
|
||||
show: { '@version': [{ _cnd: { eq: 1 } }] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1 })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.1 })).toBe(false);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2 })).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('backward compatibility with plain values', () => {
|
||||
it('should continue to work with plain value arrays', () => {
|
||||
const prop = {
|
||||
name: 'testField',
|
||||
displayOptions: {
|
||||
show: { resource: ['user', 'message'] }
|
||||
}
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { resource: 'user' })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { resource: 'message' })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { resource: 'channel' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should work with properties without displayOptions', () => {
|
||||
const prop = {
|
||||
name: 'testField'
|
||||
};
|
||||
expect(ConfigValidator.isPropertyVisible(prop, {})).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,387 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ConfigValidator } from '@/services/config-validator';
|
||||
import type { ValidationResult, ValidationError, ValidationWarning } from '@/services/config-validator';
|
||||
|
||||
// Mock the database
|
||||
vi.mock('better-sqlite3');
|
||||
|
||||
describe('ConfigValidator - Edge Cases', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Null and Undefined Handling', () => {
|
||||
it('should handle null config gracefully', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = null as any;
|
||||
const properties: any[] = [];
|
||||
|
||||
expect(() => {
|
||||
ConfigValidator.validate(nodeType, config, properties);
|
||||
}).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should handle undefined config gracefully', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = undefined as any;
|
||||
const properties: any[] = [];
|
||||
|
||||
expect(() => {
|
||||
ConfigValidator.validate(nodeType, config, properties);
|
||||
}).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should handle null properties array gracefully', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = {};
|
||||
const properties = null as any;
|
||||
|
||||
expect(() => {
|
||||
ConfigValidator.validate(nodeType, config, properties);
|
||||
}).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should handle undefined properties array gracefully', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = {};
|
||||
const properties = undefined as any;
|
||||
|
||||
expect(() => {
|
||||
ConfigValidator.validate(nodeType, config, properties);
|
||||
}).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('should handle properties with null values in config', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = {
|
||||
nullField: null,
|
||||
undefinedField: undefined,
|
||||
validField: 'value'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'nullField', type: 'string', required: true },
|
||||
{ name: 'undefinedField', type: 'string', required: true },
|
||||
{ name: 'validField', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
// Check that we have errors for both null and undefined required fields
|
||||
expect(result.errors.some(e => e.property === 'nullField')).toBe(true);
|
||||
expect(result.errors.some(e => e.property === 'undefinedField')).toBe(true);
|
||||
|
||||
// The actual error types might vary, so let's just ensure we caught the errors
|
||||
const nullFieldError = result.errors.find(e => e.property === 'nullField');
|
||||
const undefinedFieldError = result.errors.find(e => e.property === 'undefinedField');
|
||||
|
||||
expect(nullFieldError).toBeDefined();
|
||||
expect(undefinedFieldError).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Boundary Value Testing', () => {
|
||||
it('should handle empty arrays', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = {
|
||||
arrayField: []
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'arrayField', type: 'collection' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle very large property arrays', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = { field1: 'value1' };
|
||||
const properties = Array(1000).fill(null).map((_, i) => ({
|
||||
name: `field${i}`,
|
||||
type: 'string'
|
||||
}));
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle deeply nested displayOptions', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = {
|
||||
level1: 'a',
|
||||
level2: 'b',
|
||||
level3: 'c',
|
||||
deepField: 'value'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'level1', type: 'options', options: ['a', 'b'] },
|
||||
{ name: 'level2', type: 'options', options: ['a', 'b'], displayOptions: { show: { level1: ['a'] } } },
|
||||
{ name: 'level3', type: 'options', options: ['a', 'b', 'c'], displayOptions: { show: { level1: ['a'], level2: ['b'] } } },
|
||||
{ name: 'deepField', type: 'string', displayOptions: { show: { level1: ['a'], level2: ['b'], level3: ['c'] } } }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.visibleProperties).toContain('deepField');
|
||||
});
|
||||
|
||||
it('should handle extremely long string values', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const longString = 'a'.repeat(10000);
|
||||
const config = {
|
||||
longField: longString
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'longField', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid Data Type Handling', () => {
|
||||
it('should handle NaN values', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = {
|
||||
numberField: NaN
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'numberField', type: 'number' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
// NaN is technically type 'number' in JavaScript, so type validation passes
|
||||
// The validator might not have specific NaN checking, so we check for warnings
|
||||
// or just verify it doesn't crash
|
||||
expect(result).toBeDefined();
|
||||
expect(() => result).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle Infinity values', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = {
|
||||
numberField: Infinity
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'numberField', type: 'number' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
// Infinity is technically a valid number in JavaScript
|
||||
// The validator might not flag it as an error, so just verify it handles it
|
||||
expect(result).toBeDefined();
|
||||
expect(() => result).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle objects when expecting primitives', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = {
|
||||
stringField: { nested: 'object' },
|
||||
numberField: { value: 123 }
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'stringField', type: 'string' },
|
||||
{ name: 'numberField', type: 'number' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.errors).toHaveLength(2);
|
||||
expect(result.errors.every(e => e.type === 'invalid_type')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle circular references in config', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config: any = { field: 'value' };
|
||||
config.circular = config; // Create circular reference
|
||||
const properties = [
|
||||
{ name: 'field', type: 'string' },
|
||||
{ name: 'circular', type: 'json' }
|
||||
];
|
||||
|
||||
// Should not throw error
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance Boundaries', () => {
|
||||
it('should validate large config objects within reasonable time', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config: Record<string, any> = {};
|
||||
const properties: any[] = [];
|
||||
|
||||
// Create a large config with 1000 properties
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
config[`field_${i}`] = `value_${i}`;
|
||||
properties.push({
|
||||
name: `field_${i}`,
|
||||
type: 'string'
|
||||
});
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
const endTime = Date.now();
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(endTime - startTime).toBeLessThan(1000); // Should complete within 1 second
|
||||
});
|
||||
});
|
||||
|
||||
describe('Special Characters and Encoding', () => {
|
||||
it('should handle special characters in property values', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = {
|
||||
specialField: 'Value with special chars: <>&"\'`\n\r\t'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'specialField', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle unicode characters', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = {
|
||||
unicodeField: '🚀 Unicode: 你好世界 مرحبا بالعالم'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'unicodeField', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex Validation Scenarios', () => {
|
||||
it('should handle conflicting displayOptions conditions', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = {
|
||||
mode: 'both',
|
||||
showField: true,
|
||||
conflictField: 'value'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'mode', type: 'options', options: ['show', 'hide', 'both'] },
|
||||
{ name: 'showField', type: 'boolean' },
|
||||
{
|
||||
name: 'conflictField',
|
||||
type: 'string',
|
||||
displayOptions: {
|
||||
show: { mode: ['show'], showField: [true] },
|
||||
hide: { mode: ['hide'] }
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
// With mode='both', the field visibility depends on implementation
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle multiple validation profiles correctly', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'javascript',
|
||||
jsCode: 'const x = 1;'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'jsCode', type: 'string' }
|
||||
];
|
||||
|
||||
// Should perform node-specific validation for Code nodes
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.warnings.some(w =>
|
||||
w.message.includes('No return statement found')
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Recovery and Resilience', () => {
|
||||
it('should continue validation after encountering errors', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = {
|
||||
field1: 'invalid-for-number',
|
||||
field2: null, // Required field missing
|
||||
field3: 'valid'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'field1', type: 'number' },
|
||||
{ name: 'field2', type: 'string', required: true },
|
||||
{ name: 'field3', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
// Should have errors for field1 and field2, but field3 should be validated
|
||||
expect(result.errors.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Check that we have errors for field1 (type error) and field2 (required field)
|
||||
const field1Error = result.errors.find(e => e.property === 'field1');
|
||||
const field2Error = result.errors.find(e => e.property === 'field2');
|
||||
|
||||
expect(field1Error).toBeDefined();
|
||||
expect(field1Error?.type).toBe('invalid_type');
|
||||
|
||||
expect(field2Error).toBeDefined();
|
||||
// field2 is null, which might be treated as invalid_type rather than missing_required
|
||||
expect(['missing_required', 'invalid_type']).toContain(field2Error?.type);
|
||||
|
||||
expect(result.visibleProperties).toContain('field3');
|
||||
});
|
||||
|
||||
it('should handle malformed property definitions gracefully', () => {
|
||||
const nodeType = 'nodes-base.test';
|
||||
const config = { field: 'value' };
|
||||
const properties = [
|
||||
{ name: 'field', type: 'string' },
|
||||
{ /* Malformed property without name */ type: 'string' } as any,
|
||||
{ name: 'field2', /* Missing type */ } as any
|
||||
];
|
||||
|
||||
// Should handle malformed properties without crashing
|
||||
// Note: null properties will cause errors in the current implementation
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.valid).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBatch method implementation', () => {
|
||||
it('should validate multiple configs in batch if method exists', () => {
|
||||
// This test is for future implementation
|
||||
const configs = [
|
||||
{ nodeType: 'nodes-base.test', config: { field: 'value1' }, properties: [] },
|
||||
{ nodeType: 'nodes-base.test', config: { field: 'value2' }, properties: [] }
|
||||
];
|
||||
|
||||
// If validateBatch method is implemented in the future
|
||||
if ('validateBatch' in ConfigValidator) {
|
||||
const results = (ConfigValidator as any).validateBatch(configs);
|
||||
expect(results).toHaveLength(2);
|
||||
} else {
|
||||
// For now, just validate individually
|
||||
const results = configs.map(c =>
|
||||
ConfigValidator.validate(c.nodeType, c.config, c.properties)
|
||||
);
|
||||
expect(results).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,589 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ConfigValidator } from '@/services/config-validator';
|
||||
import type { ValidationResult, ValidationError, ValidationWarning } from '@/services/config-validator';
|
||||
|
||||
// Mock the database
|
||||
vi.mock('better-sqlite3');
|
||||
|
||||
describe('ConfigValidator - Node-Specific Validation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('HTTP Request node validation', () => {
|
||||
it('should perform HTTP Request specific validation', () => {
|
||||
const nodeType = 'nodes-base.httpRequest';
|
||||
const config = {
|
||||
method: 'POST',
|
||||
url: 'invalid-url', // Missing protocol
|
||||
sendBody: false
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'method', type: 'options' },
|
||||
{ name: 'url', type: 'string' },
|
||||
{ name: 'sendBody', type: 'boolean' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toMatchObject({
|
||||
type: 'invalid_value',
|
||||
property: 'url',
|
||||
message: 'URL must start with http:// or https://'
|
||||
});
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0]).toMatchObject({
|
||||
type: 'missing_common',
|
||||
property: 'sendBody',
|
||||
message: 'POST requests typically send a body'
|
||||
});
|
||||
expect(result.autofix).toMatchObject({
|
||||
sendBody: true,
|
||||
contentType: 'json'
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate HTTP Request with authentication in API URLs', () => {
|
||||
const nodeType = 'nodes-base.httpRequest';
|
||||
const config = {
|
||||
method: 'GET',
|
||||
url: 'https://api.github.com/user/repos',
|
||||
authentication: 'none'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'method', type: 'options' },
|
||||
{ name: 'url', type: 'string' },
|
||||
{ name: 'authentication', type: 'options' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.warnings.some(w =>
|
||||
w.type === 'security' &&
|
||||
w.message.includes('API endpoints typically require authentication')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate JSON in HTTP Request body', () => {
|
||||
const nodeType = 'nodes-base.httpRequest';
|
||||
const config = {
|
||||
method: 'POST',
|
||||
url: 'https://api.example.com',
|
||||
contentType: 'json',
|
||||
body: '{"invalid": json}' // Invalid JSON
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'method', type: 'options' },
|
||||
{ name: 'url', type: 'string' },
|
||||
{ name: 'contentType', type: 'options' },
|
||||
{ name: 'body', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.errors.some(e =>
|
||||
e.property === 'body' &&
|
||||
e.message.includes('Invalid JSON')
|
||||
));
|
||||
});
|
||||
|
||||
it('should handle webhook-specific validation', () => {
|
||||
const nodeType = 'nodes-base.webhook';
|
||||
const config = {
|
||||
httpMethod: 'GET',
|
||||
path: 'webhook-endpoint' // Missing leading slash
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'httpMethod', type: 'options' },
|
||||
{ name: 'path', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.warnings.some(w =>
|
||||
w.property === 'path' &&
|
||||
w.message.includes('should start with /')
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Code node validation', () => {
|
||||
it('should validate Code node configurations', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'javascript',
|
||||
jsCode: '' // Empty code
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'jsCode', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toMatchObject({
|
||||
type: 'missing_required',
|
||||
property: 'jsCode',
|
||||
message: 'Code cannot be empty'
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate JavaScript syntax in Code node', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'javascript',
|
||||
jsCode: `
|
||||
const data = { foo: "bar" };
|
||||
if (data.foo { // Missing closing parenthesis
|
||||
return [{json: data}];
|
||||
}
|
||||
`
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'jsCode', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.errors.some(e => e.message.includes('Unbalanced')));
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should validate n8n-specific patterns in Code node', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'javascript',
|
||||
jsCode: `
|
||||
// Process data without returning
|
||||
const processedData = items.map(item => ({
|
||||
...item.json,
|
||||
processed: true
|
||||
}));
|
||||
// No output provided
|
||||
`
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'jsCode', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
// The warning should be about missing return statement
|
||||
expect(result.warnings.some(w => w.type === 'missing_common' && w.message.includes('No return statement found'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty code in Code node', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'javascript',
|
||||
jsCode: ' \n \t \n ' // Just whitespace
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'jsCode', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e =>
|
||||
e.type === 'missing_required' &&
|
||||
e.message.includes('Code cannot be empty')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate complex return patterns in Code node', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'javascript',
|
||||
jsCode: `
|
||||
return ["string1", "string2", "string3"];
|
||||
`
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'jsCode', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.warnings.some(w =>
|
||||
w.type === 'invalid_value' &&
|
||||
w.message.includes('Items must be objects with json property')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate Code node with $helpers usage', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'javascript',
|
||||
jsCode: `
|
||||
const workflow = $helpers.getWorkflowStaticData();
|
||||
workflow.counter = (workflow.counter || 0) + 1;
|
||||
return [{json: {count: workflow.counter}}];
|
||||
`
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'jsCode', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.warnings.some(w =>
|
||||
w.type === 'best_practice' &&
|
||||
w.message.includes('$helpers is only available in Code nodes')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect incorrect $helpers.getWorkflowStaticData usage', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'javascript',
|
||||
jsCode: `
|
||||
const data = $helpers.getWorkflowStaticData; // Missing parentheses
|
||||
return [{json: {data}}];
|
||||
`
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'jsCode', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.errors.some(e =>
|
||||
e.type === 'invalid_value' &&
|
||||
e.message.includes('getWorkflowStaticData requires parentheses')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate console.log usage', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'javascript',
|
||||
jsCode: `
|
||||
console.log('Debug info:', items);
|
||||
return items;
|
||||
`
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'jsCode', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.warnings.some(w =>
|
||||
w.type === 'best_practice' &&
|
||||
w.message.includes('console.log output appears in n8n execution logs')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate $json usage warning', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'javascript',
|
||||
jsCode: `
|
||||
const data = $json.myField;
|
||||
return [{json: {processed: data}}];
|
||||
`
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'jsCode', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.warnings.some(w =>
|
||||
w.type === 'best_practice' &&
|
||||
w.message.includes('$json only works in "Run Once for Each Item" mode')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not warn about properties for Code nodes', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'javascript',
|
||||
jsCode: 'return items;',
|
||||
unusedProperty: 'this should not generate a warning for Code nodes'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'jsCode', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
// Code nodes should skip the common issues check that warns about unused properties
|
||||
expect(result.warnings.some(w =>
|
||||
w.type === 'inefficient' &&
|
||||
w.property === 'unusedProperty'
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate crypto module usage', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'javascript',
|
||||
jsCode: `
|
||||
const uuid = crypto.randomUUID();
|
||||
return [{json: {id: uuid}}];
|
||||
`
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'jsCode', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.warnings.some(w =>
|
||||
w.type === 'invalid_value' &&
|
||||
w.message.includes('Using crypto without require')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should suggest error handling for complex code', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'javascript',
|
||||
jsCode: `
|
||||
const apiUrl = items[0].json.url;
|
||||
const response = await fetch(apiUrl);
|
||||
const data = await response.json();
|
||||
return [{json: data}];
|
||||
`
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'jsCode', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.suggestions.some(s =>
|
||||
s.includes('Consider adding error handling')
|
||||
));
|
||||
});
|
||||
|
||||
it('should suggest error handling for non-trivial code', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'javascript',
|
||||
jsCode: Array(10).fill('const x = 1;').join('\n') + '\nreturn items;'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'jsCode', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.suggestions.some(s => s.includes('error handling')));
|
||||
});
|
||||
|
||||
it('should validate async operations without await', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'javascript',
|
||||
jsCode: `
|
||||
const promise = fetch('https://api.example.com');
|
||||
return [{json: {data: promise}}];
|
||||
`
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'jsCode', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.warnings.some(w =>
|
||||
w.type === 'best_practice' &&
|
||||
w.message.includes('Async operation without await')
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Python Code node validation', () => {
|
||||
it('should validate Python code syntax', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'python',
|
||||
pythonCode: `
|
||||
def process_data():
|
||||
return [{"json": {"test": True}] # Missing closing bracket
|
||||
`
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'pythonCode', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.errors.some(e =>
|
||||
e.type === 'syntax_error' &&
|
||||
e.message.includes('Unmatched bracket')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect mixed indentation in Python code', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'python',
|
||||
pythonCode: `
|
||||
def process():
|
||||
x = 1
|
||||
y = 2 # This line uses tabs
|
||||
return [{"json": {"x": x, "y": y}}]
|
||||
`
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'pythonCode', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.errors.some(e =>
|
||||
e.type === 'syntax_error' &&
|
||||
e.message.includes('Mixed indentation')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should warn about incorrect n8n return patterns', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'python',
|
||||
pythonCode: `
|
||||
result = {"data": "value"}
|
||||
return result # Should return array of objects with json key
|
||||
`
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'pythonCode', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.warnings.some(w =>
|
||||
w.type === 'invalid_value' &&
|
||||
w.message.includes('Must return array of objects with json key')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should warn about using external libraries in Python code', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'python',
|
||||
pythonCode: `
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
df = pd.DataFrame(items)
|
||||
response = requests.get('https://api.example.com')
|
||||
return [{"json": {"data": response.json()}}]
|
||||
`
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'pythonCode', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.warnings.some(w =>
|
||||
w.type === 'invalid_value' &&
|
||||
w.message.includes('External libraries not available')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate Python code with print statements', () => {
|
||||
const nodeType = 'nodes-base.code';
|
||||
const config = {
|
||||
language: 'python',
|
||||
pythonCode: `
|
||||
print("Debug:", items)
|
||||
processed = []
|
||||
for item in items:
|
||||
print(f"Processing: {item}")
|
||||
processed.append({"json": item["json"]})
|
||||
return processed
|
||||
`
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'language', type: 'options' },
|
||||
{ name: 'pythonCode', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.warnings.some(w =>
|
||||
w.type === 'best_practice' &&
|
||||
w.message.includes('print() output appears in n8n execution logs')
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Database node validation', () => {
|
||||
it('should validate database query security', () => {
|
||||
const nodeType = 'nodes-base.postgres';
|
||||
const config = {
|
||||
query: 'DELETE FROM users;' // Missing WHERE clause
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'query', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.warnings.some(w =>
|
||||
w.type === 'security' &&
|
||||
w.message.includes('DELETE query without WHERE clause')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should check for SQL injection vulnerabilities', () => {
|
||||
const nodeType = 'nodes-base.mysql';
|
||||
const config = {
|
||||
query: 'SELECT * FROM users WHERE id = ${userId}'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'query', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.warnings.some(w =>
|
||||
w.type === 'security' &&
|
||||
w.message.includes('SQL injection')
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate SQL SELECT * performance warning', () => {
|
||||
const nodeType = 'nodes-base.postgres';
|
||||
const config = {
|
||||
query: 'SELECT * FROM large_table WHERE status = "active"'
|
||||
};
|
||||
const properties = [
|
||||
{ name: 'query', type: 'string' }
|
||||
];
|
||||
|
||||
const result = ConfigValidator.validate(nodeType, config, properties);
|
||||
|
||||
expect(result.suggestions.some(s =>
|
||||
s.includes('Consider selecting specific columns')
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
667
tests/unit/services/config-validator.test.ts
Normal file
667
tests/unit/services/config-validator.test.ts
Normal file
@@ -0,0 +1,667 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ConfigValidator } from '@/services/config-validator';
|
||||
import type { ValidationResult, ValidationError, ValidationWarning } from '@/services/config-validator';
|
||||
|
||||
// Mock the database
|
||||
vi.mock('better-sqlite3');
|
||||
|
||||
describe('ConfigValidator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ─── Basic Validation ───────────────────────────────────────────────
|
||||
|
||||
describe('validate', () => {
|
||||
it('should validate required fields for Slack message post', () => {
|
||||
const config = { resource: 'message', operation: 'post' };
|
||||
const properties = [
|
||||
{ name: 'resource', type: 'options', required: true, default: 'message', options: [{ name: 'Message', value: 'message' }, { name: 'Channel', value: 'channel' }] },
|
||||
{ name: 'operation', type: 'options', required: true, default: 'post', displayOptions: { show: { resource: ['message'] } }, options: [{ name: 'Post', value: 'post' }, { name: 'Update', value: 'update' }] },
|
||||
{ name: 'channel', type: 'string', required: true, displayOptions: { show: { resource: ['message'], operation: ['post'] } } }
|
||||
];
|
||||
const result = ConfigValidator.validate('nodes-base.slack', config, properties);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toMatchObject({ type: 'missing_required', property: 'channel', message: "Required property 'channel' is missing", fix: 'Add channel to your configuration' });
|
||||
});
|
||||
|
||||
it('should validate successfully with all required fields', () => {
|
||||
const config = { resource: 'message', operation: 'post', channel: '#general', text: 'Hello, Slack!' };
|
||||
const properties = [
|
||||
{ name: 'resource', type: 'options', required: true, default: 'message', options: [{ name: 'Message', value: 'message' }, { name: 'Channel', value: 'channel' }] },
|
||||
{ name: 'operation', type: 'options', required: true, default: 'post', displayOptions: { show: { resource: ['message'] } }, options: [{ name: 'Post', value: 'post' }, { name: 'Update', value: 'update' }] },
|
||||
{ name: 'channel', type: 'string', required: true, displayOptions: { show: { resource: ['message'], operation: ['post'] } } },
|
||||
{ name: 'text', type: 'string', default: '', displayOptions: { show: { resource: ['message'], operation: ['post'] } } }
|
||||
];
|
||||
const result = ConfigValidator.validate('nodes-base.slack', config, properties);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle unknown node types gracefully', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.unknown', { field: 'value' }, []);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should validate property types', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.test', { numberField: 'not-a-number', booleanField: 'yes' }, [{ name: 'numberField', type: 'number' }, { name: 'booleanField', type: 'boolean' }]);
|
||||
expect(result.errors).toHaveLength(2);
|
||||
expect(result.errors.some(e => e.property === 'numberField' && e.type === 'invalid_type')).toBe(true);
|
||||
expect(result.errors.some(e => e.property === 'booleanField' && e.type === 'invalid_type')).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate option values', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.test', { selectField: 'invalid-option' }, [{ name: 'selectField', type: 'options', options: [{ name: 'Option A', value: 'a' }, { name: 'Option B', value: 'b' }] }]);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toMatchObject({ type: 'invalid_value', property: 'selectField', message: expect.stringContaining('Invalid value') });
|
||||
});
|
||||
|
||||
it('should check property visibility based on displayOptions', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.test', { resource: 'user', userField: 'visible' }, [
|
||||
{ name: 'resource', type: 'options', options: [{ name: 'User', value: 'user' }, { name: 'Post', value: 'post' }] },
|
||||
{ name: 'userField', type: 'string', displayOptions: { show: { resource: ['user'] } } },
|
||||
{ name: 'postField', type: 'string', displayOptions: { show: { resource: ['post'] } } }
|
||||
]);
|
||||
expect(result.visibleProperties).toContain('resource');
|
||||
expect(result.visibleProperties).toContain('userField');
|
||||
expect(result.hiddenProperties).toContain('postField');
|
||||
});
|
||||
|
||||
it('should handle empty properties array', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.test', { someField: 'value' }, []);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle missing displayOptions gracefully', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.test', { field1: 'value1' }, [{ name: 'field1', type: 'string' }]);
|
||||
expect(result.visibleProperties).toContain('field1');
|
||||
});
|
||||
|
||||
it('should validate options with array format', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.test', { optionField: 'b' }, [{ name: 'optionField', type: 'options', options: [{ name: 'Option A', value: 'a' }, { name: 'Option B', value: 'b' }, { name: 'Option C', value: 'c' }] }]);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Edge Cases and Additional Coverage ─────────────────────────────
|
||||
|
||||
describe('edge cases and additional coverage', () => {
|
||||
it('should handle null and undefined config values', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.test', { nullField: null, undefinedField: undefined, validField: 'value' }, [
|
||||
{ name: 'nullField', type: 'string', required: true },
|
||||
{ name: 'undefinedField', type: 'string', required: true },
|
||||
{ name: 'validField', type: 'string' }
|
||||
]);
|
||||
expect(result.errors.find(e => e.property === 'nullField')).toBeDefined();
|
||||
expect(result.errors.find(e => e.property === 'undefinedField')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should validate nested displayOptions conditions', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.test', { mode: 'advanced', resource: 'user', advancedUserField: 'value' }, [
|
||||
{ name: 'mode', type: 'options', options: [{ name: 'Simple', value: 'simple' }, { name: 'Advanced', value: 'advanced' }] },
|
||||
{ name: 'resource', type: 'options', displayOptions: { show: { mode: ['advanced'] } }, options: [{ name: 'User', value: 'user' }, { name: 'Post', value: 'post' }] },
|
||||
{ name: 'advancedUserField', type: 'string', displayOptions: { show: { mode: ['advanced'], resource: ['user'] } } }
|
||||
]);
|
||||
expect(result.visibleProperties).toContain('advancedUserField');
|
||||
});
|
||||
|
||||
it('should handle hide conditions in displayOptions', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.test', { showAdvanced: false, hiddenField: 'should-not-be-here' }, [
|
||||
{ name: 'showAdvanced', type: 'boolean' },
|
||||
{ name: 'hiddenField', type: 'string', displayOptions: { hide: { showAdvanced: [false] } } }
|
||||
]);
|
||||
expect(result.hiddenProperties).toContain('hiddenField');
|
||||
expect(result.warnings.some(w => w.property === 'hiddenField' && w.type === 'inefficient')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle internal properties that start with underscore', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.test', { '@version': 1, '_internalField': 'value', normalField: 'value' }, [{ name: 'normalField', type: 'string' }]);
|
||||
expect(result.warnings.some(w => w.property === '@version' || w.property === '_internalField')).toBe(false);
|
||||
});
|
||||
|
||||
it('should warn about inefficient configured but hidden properties', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.test', { mode: 'manual', automaticField: 'This will not be used' }, [
|
||||
{ name: 'mode', type: 'options', options: [{ name: 'Manual', value: 'manual' }, { name: 'Automatic', value: 'automatic' }] },
|
||||
{ name: 'automaticField', type: 'string', displayOptions: { show: { mode: ['automatic'] } } }
|
||||
]);
|
||||
expect(result.warnings.some(w => w.type === 'inefficient' && w.property === 'automaticField' && w.message.includes("won't be used"))).toBe(true);
|
||||
});
|
||||
|
||||
it('should suggest commonly used properties', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.httpRequest', { method: 'GET', url: 'https://api.example.com/data' }, [{ name: 'method', type: 'options' }, { name: 'url', type: 'string' }, { name: 'headers', type: 'json' }]);
|
||||
expect(result.suggestions.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── ResourceLocator Validation ─────────────────────────────────────
|
||||
|
||||
describe('resourceLocator validation', () => {
|
||||
const rlNodeType = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
|
||||
|
||||
it('should reject string value when resourceLocator object is required', () => {
|
||||
const result = ConfigValidator.validate(rlNodeType, { model: 'gpt-4o-mini' }, [{ name: 'model', displayName: 'Model', type: 'resourceLocator', required: true, default: { mode: 'list', value: 'gpt-4o-mini' } }]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toMatchObject({ type: 'invalid_type', property: 'model', message: expect.stringContaining('must be an object with \'mode\' and \'value\' properties') });
|
||||
expect(result.errors[0].fix).toContain('mode');
|
||||
expect(result.errors[0].fix).toContain('value');
|
||||
});
|
||||
|
||||
it('should accept valid resourceLocator with mode and value', () => {
|
||||
const result = ConfigValidator.validate(rlNodeType, { model: { mode: 'list', value: 'gpt-4o-mini' } }, [{ name: 'model', displayName: 'Model', type: 'resourceLocator', required: true, default: { mode: 'list', value: 'gpt-4o-mini' } }]);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject null value for resourceLocator', () => {
|
||||
const result = ConfigValidator.validate(rlNodeType, { model: null }, [{ name: 'model', type: 'resourceLocator', required: true }]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.property === 'model' && e.type === 'invalid_type')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject array value for resourceLocator', () => {
|
||||
const result = ConfigValidator.validate(rlNodeType, { model: ['gpt-4o-mini'] }, [{ name: 'model', type: 'resourceLocator', required: true }]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.property === 'model' && e.type === 'invalid_type' && e.message.includes('must be an object'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect missing mode property in resourceLocator', () => {
|
||||
const result = ConfigValidator.validate(rlNodeType, { model: { value: 'gpt-4o-mini' } }, [{ name: 'model', type: 'resourceLocator', required: true }]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.property === 'model.mode' && e.type === 'missing_required' && e.message.includes('missing required property \'mode\''))).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect missing value property in resourceLocator', () => {
|
||||
const result = ConfigValidator.validate(rlNodeType, { model: { mode: 'list' } }, [{ name: 'model', displayName: 'Model', type: 'resourceLocator', required: true }]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.property === 'model.value' && e.type === 'missing_required' && e.message.includes('missing required property \'value\''))).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect invalid mode type in resourceLocator', () => {
|
||||
const result = ConfigValidator.validate(rlNodeType, { model: { mode: 123, value: 'gpt-4o-mini' } }, [{ name: 'model', type: 'resourceLocator', required: true }]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.property === 'model.mode' && e.type === 'invalid_type' && e.message.includes('must be a string'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept resourceLocator with mode "id"', () => {
|
||||
const result = ConfigValidator.validate(rlNodeType, { model: { mode: 'id', value: 'gpt-4o-2024-11-20' } }, [{ name: 'model', type: 'resourceLocator', required: true }]);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject number value when resourceLocator is required', () => {
|
||||
const result = ConfigValidator.validate(rlNodeType, { model: 12345 }, [{ name: 'model', type: 'resourceLocator', required: true }]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0].type).toBe('invalid_type');
|
||||
expect(result.errors[0].message).toContain('must be an object');
|
||||
});
|
||||
|
||||
it('should provide helpful fix suggestion for string to resourceLocator conversion', () => {
|
||||
const result = ConfigValidator.validate(rlNodeType, { model: 'gpt-4o-mini' }, [{ name: 'model', type: 'resourceLocator', required: true }]);
|
||||
expect(result.errors[0].fix).toContain('{ mode: "list", value: "gpt-4o-mini" }');
|
||||
expect(result.errors[0].fix).toContain('{ mode: "id", value: "gpt-4o-mini" }');
|
||||
});
|
||||
|
||||
it('should reject invalid mode values when schema defines allowed modes', () => {
|
||||
const result = ConfigValidator.validate(rlNodeType, { model: { mode: 'invalid-mode', value: 'gpt-4o-mini' } }, [{ name: 'model', type: 'resourceLocator', required: true, modes: [{ name: 'list', displayName: 'List' }, { name: 'id', displayName: 'ID' }, { name: 'url', displayName: 'URL' }] }]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.property === 'model.mode' && e.type === 'invalid_value' && e.message.includes('must be one of [list, id, url]'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle modes defined as array format', () => {
|
||||
const result = ConfigValidator.validate(rlNodeType, { model: { mode: 'custom', value: 'gpt-4o-mini' } }, [{ name: 'model', type: 'resourceLocator', required: true, modes: [{ name: 'list', displayName: 'List' }, { name: 'id', displayName: 'ID' }, { name: 'custom', displayName: 'Custom' }] }]);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle malformed modes schema gracefully', () => {
|
||||
const result = ConfigValidator.validate(rlNodeType, { model: { mode: 'any-mode', value: 'gpt-4o-mini' } }, [{ name: 'model', type: 'resourceLocator', required: true, modes: 'invalid-string' }]);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.some(e => e.property === 'model.mode')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty modes definition gracefully', () => {
|
||||
const result = ConfigValidator.validate(rlNodeType, { model: { mode: 'any-mode', value: 'gpt-4o-mini' } }, [{ name: 'model', type: 'resourceLocator', required: true, modes: {} }]);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors.some(e => e.property === 'model.mode')).toBe(false);
|
||||
});
|
||||
|
||||
it('should skip mode validation when modes not provided', () => {
|
||||
const result = ConfigValidator.validate(rlNodeType, { model: { mode: 'custom-mode', value: 'gpt-4o-mini' } }, [{ name: 'model', type: 'resourceLocator', required: true }]);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept resourceLocator with mode "url"', () => {
|
||||
const result = ConfigValidator.validate(rlNodeType, { model: { mode: 'url', value: 'https://api.example.com/models/custom' } }, [{ name: 'model', type: 'resourceLocator', required: true }]);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect empty resourceLocator object', () => {
|
||||
const result = ConfigValidator.validate(rlNodeType, { model: {} }, [{ name: 'model', type: 'resourceLocator', required: true }]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThanOrEqual(2);
|
||||
expect(result.errors.some(e => e.property === 'model.mode')).toBe(true);
|
||||
expect(result.errors.some(e => e.property === 'model.value')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle resourceLocator with extra properties gracefully', () => {
|
||||
const result = ConfigValidator.validate(rlNodeType, { model: { mode: 'list', value: 'gpt-4o-mini', extraProperty: 'ignored' } }, [{ name: 'model', type: 'resourceLocator', required: true }]);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── _cnd Operators (from config-validator-cnd) ─────────────────────
|
||||
|
||||
describe('_cnd operators', () => {
|
||||
describe('eq operator', () => {
|
||||
it('should match when values are equal', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'testField', displayOptions: { show: { status: [{ _cnd: { eq: 'active' } }] } } }, { status: 'active' })).toBe(true);
|
||||
});
|
||||
it('should not match when values are not equal', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'testField', displayOptions: { show: { status: [{ _cnd: { eq: 'active' } }] } } }, { status: 'inactive' })).toBe(false);
|
||||
});
|
||||
it('should match numeric equality', () => {
|
||||
const prop = { name: 'testField', displayOptions: { show: { '@version': [{ _cnd: { eq: 1 } }] } } };
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1 })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('not operator', () => {
|
||||
it('should match when values are not equal', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'testField', displayOptions: { show: { status: [{ _cnd: { not: 'disabled' } }] } } }, { status: 'active' })).toBe(true);
|
||||
});
|
||||
it('should not match when values are equal', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'testField', displayOptions: { show: { status: [{ _cnd: { not: 'disabled' } }] } } }, { status: 'disabled' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gte operator', () => {
|
||||
it('should match when value is greater', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { '@version': [{ _cnd: { gte: 1.1 } }] } } }, { '@version': 2.0 })).toBe(true);
|
||||
});
|
||||
it('should match when value is equal', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { '@version': [{ _cnd: { gte: 1.1 } }] } } }, { '@version': 1.1 })).toBe(true);
|
||||
});
|
||||
it('should not match when value is less', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { '@version': [{ _cnd: { gte: 1.1 } }] } } }, { '@version': 1.0 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lte operator', () => {
|
||||
it('should match when value is less', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { '@version': [{ _cnd: { lte: 2.0 } }] } } }, { '@version': 1.5 })).toBe(true);
|
||||
});
|
||||
it('should match when value is equal', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { '@version': [{ _cnd: { lte: 2.0 } }] } } }, { '@version': 2.0 })).toBe(true);
|
||||
});
|
||||
it('should not match when value is greater', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { '@version': [{ _cnd: { lte: 2.0 } }] } } }, { '@version': 2.5 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gt operator', () => {
|
||||
it('should match when value is greater', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { count: [{ _cnd: { gt: 5 } }] } } }, { count: 10 })).toBe(true);
|
||||
});
|
||||
it('should not match when value is equal', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { count: [{ _cnd: { gt: 5 } }] } } }, { count: 5 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lt operator', () => {
|
||||
it('should match when value is less', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { count: [{ _cnd: { lt: 10 } }] } } }, { count: 5 })).toBe(true);
|
||||
});
|
||||
it('should not match when value is equal', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { count: [{ _cnd: { lt: 10 } }] } } }, { count: 10 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('between operator', () => {
|
||||
it('should match when value is within range', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { '@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }] } } }, { '@version': 4.3 })).toBe(true);
|
||||
});
|
||||
it('should match when value equals lower bound', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { '@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }] } } }, { '@version': 4 })).toBe(true);
|
||||
});
|
||||
it('should match when value equals upper bound', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { '@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }] } } }, { '@version': 4.6 })).toBe(true);
|
||||
});
|
||||
it('should not match when value is below range', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { '@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }] } } }, { '@version': 3.9 })).toBe(false);
|
||||
});
|
||||
it('should not match when value is above range', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { '@version': [{ _cnd: { between: { from: 4, to: 4.6 } } }] } } }, { '@version': 5 })).toBe(false);
|
||||
});
|
||||
it('should not match when between structure is null', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { '@version': [{ _cnd: { between: null } }] } } }, { '@version': 4 })).toBe(false);
|
||||
});
|
||||
it('should not match when between is missing from field', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { '@version': [{ _cnd: { between: { to: 5 } } }] } } }, { '@version': 4 })).toBe(false);
|
||||
});
|
||||
it('should not match when between is missing to field', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { '@version': [{ _cnd: { between: { from: 3 } } }] } } }, { '@version': 4 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('startsWith operator', () => {
|
||||
it('should match when string starts with prefix', () => { expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { name: [{ _cnd: { startsWith: 'test' } }] } } }, { name: 'testUser' })).toBe(true); });
|
||||
it('should not match when string does not start with prefix', () => { expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { name: [{ _cnd: { startsWith: 'test' } }] } } }, { name: 'mytest' })).toBe(false); });
|
||||
it('should not match non-string values', () => { expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { value: [{ _cnd: { startsWith: 'test' } }] } } }, { value: 123 })).toBe(false); });
|
||||
});
|
||||
|
||||
describe('endsWith operator', () => {
|
||||
it('should match when string ends with suffix', () => { expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { email: [{ _cnd: { endsWith: '@example.com' } }] } } }, { email: 'user@example.com' })).toBe(true); });
|
||||
it('should not match when string does not end with suffix', () => { expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { email: [{ _cnd: { endsWith: '@example.com' } }] } } }, { email: 'user@other.com' })).toBe(false); });
|
||||
});
|
||||
|
||||
describe('includes operator', () => {
|
||||
it('should match when string contains substring', () => { expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { eventId: [{ _cnd: { includes: '_' } }] } } }, { eventId: 'event_123' })).toBe(true); });
|
||||
it('should not match when string does not contain substring', () => { expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { eventId: [{ _cnd: { includes: '_' } }] } } }, { eventId: 'event123' })).toBe(false); });
|
||||
});
|
||||
|
||||
describe('regex operator', () => {
|
||||
it('should match when string matches regex pattern', () => { expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { id: [{ _cnd: { regex: '^[A-Z]{3}\\d{4}$' } }] } } }, { id: 'ABC1234' })).toBe(true); });
|
||||
it('should not match when string does not match regex pattern', () => { expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { id: [{ _cnd: { regex: '^[A-Z]{3}\\d{4}$' } }] } } }, { id: 'abc1234' })).toBe(false); });
|
||||
it('should not match when regex pattern is invalid', () => { expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { id: [{ _cnd: { regex: '[invalid(regex' } }] } } }, { id: 'test' })).toBe(false); });
|
||||
it('should not match non-string values', () => { expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { value: [{ _cnd: { regex: '\\d+' } }] } } }, { value: 123 })).toBe(false); });
|
||||
});
|
||||
|
||||
describe('exists operator', () => {
|
||||
it('should match when field exists and is not null', () => { expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { optionalField: [{ _cnd: { exists: true } }] } } }, { optionalField: 'value' })).toBe(true); });
|
||||
it('should match when field exists with value 0', () => { expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { optionalField: [{ _cnd: { exists: true } }] } } }, { optionalField: 0 })).toBe(true); });
|
||||
it('should match when field exists with empty string', () => { expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { optionalField: [{ _cnd: { exists: true } }] } } }, { optionalField: '' })).toBe(true); });
|
||||
it('should not match when field is undefined', () => { expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { optionalField: [{ _cnd: { exists: true } }] } } }, { otherField: 'value' })).toBe(false); });
|
||||
it('should not match when field is null', () => { expect(ConfigValidator.isPropertyVisible({ name: 'f', displayOptions: { show: { optionalField: [{ _cnd: { exists: true } }] } } }, { optionalField: null })).toBe(false); });
|
||||
});
|
||||
|
||||
describe('mixed plain values and _cnd conditions', () => {
|
||||
it('should match plain value in array with _cnd', () => {
|
||||
const prop = { name: 'f', displayOptions: { show: { status: ['active', { _cnd: { eq: 'pending' } }] } } };
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { status: 'active' })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { status: 'pending' })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { status: 'disabled' })).toBe(false);
|
||||
});
|
||||
it('should handle multiple conditions with AND logic', () => {
|
||||
const prop = { name: 'f', displayOptions: { show: { '@version': [{ _cnd: { gte: 1.1 } }], mode: ['advanced'] } } };
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.0, mode: 'advanced' })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.0, mode: 'basic' })).toBe(false);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.0, mode: 'advanced' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hide conditions with _cnd', () => {
|
||||
it('should hide property when _cnd condition matches', () => {
|
||||
const prop = { name: 'f', displayOptions: { hide: { '@version': [{ _cnd: { lt: 2.0 } }] } } };
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.5 })).toBe(false);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.0 })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.5 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Execute Workflow Trigger scenario', () => {
|
||||
it('should show property when @version >= 1.1', () => {
|
||||
const prop = { name: 'inputSource', displayOptions: { show: { '@version': [{ _cnd: { gte: 1.1 } }] } } };
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.1 })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.2 })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2.0 })).toBe(true);
|
||||
});
|
||||
it('should hide property when @version < 1.1', () => {
|
||||
const prop = { name: 'inputSource', displayOptions: { show: { '@version': [{ _cnd: { gte: 1.1 } }] } } };
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.0 })).toBe(false);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1 })).toBe(false);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 0.9 })).toBe(false);
|
||||
});
|
||||
it('should show outdated version warning only for v1', () => {
|
||||
const prop = { name: 'outdatedVersionWarning', displayOptions: { show: { '@version': [{ _cnd: { eq: 1 } }] } } };
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1 })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 1.1 })).toBe(false);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { '@version': 2 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('backward compatibility with plain values', () => {
|
||||
it('should continue to work with plain value arrays', () => {
|
||||
const prop = { name: 'f', displayOptions: { show: { resource: ['user', 'message'] } } };
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { resource: 'user' })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { resource: 'message' })).toBe(true);
|
||||
expect(ConfigValidator.isPropertyVisible(prop, { resource: 'channel' })).toBe(false);
|
||||
});
|
||||
it('should work with properties without displayOptions', () => {
|
||||
expect(ConfigValidator.isPropertyVisible({ name: 'f' }, {})).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Null/Undefined Handling (from edge-cases) ─────────────────────
|
||||
|
||||
describe('null and undefined handling', () => {
|
||||
it('should handle null config gracefully', () => { expect(() => { ConfigValidator.validate('nodes-base.test', null as any, []); }).toThrow(TypeError); });
|
||||
it('should handle undefined config gracefully', () => { expect(() => { ConfigValidator.validate('nodes-base.test', undefined as any, []); }).toThrow(TypeError); });
|
||||
it('should handle null properties array gracefully', () => { expect(() => { ConfigValidator.validate('nodes-base.test', {}, null as any); }).toThrow(TypeError); });
|
||||
it('should handle undefined properties array gracefully', () => { expect(() => { ConfigValidator.validate('nodes-base.test', {}, undefined as any); }).toThrow(TypeError); });
|
||||
});
|
||||
|
||||
// ─── Boundary Value Testing (from edge-cases) ─────────────────────
|
||||
|
||||
describe('boundary value testing', () => {
|
||||
it('should handle empty arrays', () => { expect(ConfigValidator.validate('nodes-base.test', { arrayField: [] }, [{ name: 'arrayField', type: 'collection' }]).valid).toBe(true); });
|
||||
it('should handle very large property arrays', () => { expect(ConfigValidator.validate('nodes-base.test', { field1: 'value1' }, Array(1000).fill(null).map((_, i) => ({ name: `field${i}`, type: 'string' }))).valid).toBe(true); });
|
||||
it('should handle deeply nested displayOptions', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.test', { level1: 'a', level2: 'b', level3: 'c', deepField: 'value' }, [
|
||||
{ name: 'level1', type: 'options', options: ['a', 'b'] },
|
||||
{ name: 'level2', type: 'options', options: ['a', 'b'], displayOptions: { show: { level1: ['a'] } } },
|
||||
{ name: 'level3', type: 'options', options: ['a', 'b', 'c'], displayOptions: { show: { level1: ['a'], level2: ['b'] } } },
|
||||
{ name: 'deepField', type: 'string', displayOptions: { show: { level1: ['a'], level2: ['b'], level3: ['c'] } } }
|
||||
]);
|
||||
expect(result.visibleProperties).toContain('deepField');
|
||||
});
|
||||
it('should handle extremely long string values', () => { expect(ConfigValidator.validate('nodes-base.test', { longField: 'a'.repeat(10000) }, [{ name: 'longField', type: 'string' }]).valid).toBe(true); });
|
||||
});
|
||||
|
||||
// ─── Invalid Data Type Handling (from edge-cases) ─────────────────
|
||||
|
||||
describe('invalid data type handling', () => {
|
||||
it('should handle NaN values', () => { expect(ConfigValidator.validate('nodes-base.test', { numberField: NaN }, [{ name: 'numberField', type: 'number' }])).toBeDefined(); });
|
||||
it('should handle Infinity values', () => { expect(ConfigValidator.validate('nodes-base.test', { numberField: Infinity }, [{ name: 'numberField', type: 'number' }])).toBeDefined(); });
|
||||
it('should handle objects when expecting primitives', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.test', { stringField: { nested: 'object' }, numberField: { value: 123 } }, [{ name: 'stringField', type: 'string' }, { name: 'numberField', type: 'number' }]);
|
||||
expect(result.errors).toHaveLength(2);
|
||||
expect(result.errors.every(e => e.type === 'invalid_type')).toBe(true);
|
||||
});
|
||||
it('should handle circular references in config', () => {
|
||||
const config: any = { field: 'value' };
|
||||
config.circular = config;
|
||||
expect(ConfigValidator.validate('nodes-base.test', config, [{ name: 'field', type: 'string' }, { name: 'circular', type: 'json' }])).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Performance Boundaries (from edge-cases) ─────────────────────
|
||||
|
||||
describe('performance boundaries', () => {
|
||||
it('should validate large config objects within reasonable time', () => {
|
||||
const config: Record<string, any> = {};
|
||||
const properties: any[] = [];
|
||||
for (let i = 0; i < 1000; i++) { config[`field_${i}`] = `value_${i}`; properties.push({ name: `field_${i}`, type: 'string' }); }
|
||||
const startTime = Date.now();
|
||||
const result = ConfigValidator.validate('nodes-base.test', config, properties);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(Date.now() - startTime).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Special Characters (from edge-cases) ─────────────────────────
|
||||
|
||||
describe('special characters and encoding', () => {
|
||||
it('should handle special characters in property values', () => { expect(ConfigValidator.validate('nodes-base.test', { specialField: 'Value with special chars: <>&"\'`\n\r\t' }, [{ name: 'specialField', type: 'string' }]).valid).toBe(true); });
|
||||
it('should handle unicode characters', () => { expect(ConfigValidator.validate('nodes-base.test', { unicodeField: 'Unicode: \u4F60\u597D\u4E16\u754C' }, [{ name: 'unicodeField', type: 'string' }]).valid).toBe(true); });
|
||||
});
|
||||
|
||||
// ─── Complex Validation Scenarios (from edge-cases) ───────────────
|
||||
|
||||
describe('complex validation scenarios', () => {
|
||||
it('should handle conflicting displayOptions conditions', () => {
|
||||
expect(ConfigValidator.validate('nodes-base.test', { mode: 'both', showField: true, conflictField: 'value' }, [
|
||||
{ name: 'mode', type: 'options', options: ['show', 'hide', 'both'] },
|
||||
{ name: 'showField', type: 'boolean' },
|
||||
{ name: 'conflictField', type: 'string', displayOptions: { show: { mode: ['show'], showField: [true] }, hide: { mode: ['hide'] } } }
|
||||
])).toBeDefined();
|
||||
});
|
||||
it('should handle multiple validation profiles correctly', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.code', { language: 'javascript', jsCode: 'const x = 1;' }, [{ name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' }]);
|
||||
expect(result.warnings.some(w => w.message.includes('No return statement found'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error Recovery (from edge-cases) ─────────────────────────────
|
||||
|
||||
describe('error recovery and resilience', () => {
|
||||
it('should continue validation after encountering errors', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.test', { field1: 'invalid-for-number', field2: null, field3: 'valid' }, [{ name: 'field1', type: 'number' }, { name: 'field2', type: 'string', required: true }, { name: 'field3', type: 'string' }]);
|
||||
expect(result.errors.length).toBeGreaterThanOrEqual(2);
|
||||
expect(result.errors.find(e => e.property === 'field1')?.type).toBe('invalid_type');
|
||||
expect(result.errors.find(e => e.property === 'field2')).toBeDefined();
|
||||
expect(result.visibleProperties).toContain('field3');
|
||||
});
|
||||
it('should handle malformed property definitions gracefully', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.test', { field: 'value' }, [{ name: 'field', type: 'string' }, { type: 'string' } as any, { name: 'field2' } as any]);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.valid).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Batch Validation (from edge-cases) ───────────────────────────
|
||||
|
||||
describe('validateBatch method implementation', () => {
|
||||
it('should validate multiple configs in batch if method exists', () => {
|
||||
const configs = [{ nodeType: 'nodes-base.test', config: { field: 'value1' }, properties: [] as any[] }, { nodeType: 'nodes-base.test', config: { field: 'value2' }, properties: [] as any[] }];
|
||||
if ('validateBatch' in ConfigValidator) { expect((ConfigValidator as any).validateBatch(configs)).toHaveLength(2); }
|
||||
else { expect(configs.map(c => ConfigValidator.validate(c.nodeType, c.config, c.properties))).toHaveLength(2); }
|
||||
});
|
||||
});
|
||||
|
||||
// ─── HTTP Request Node (from node-specific) ──────────────────────
|
||||
|
||||
describe('HTTP Request node validation', () => {
|
||||
it('should perform HTTP Request specific validation', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.httpRequest', { method: 'POST', url: 'invalid-url', sendBody: false }, [{ name: 'method', type: 'options' }, { name: 'url', type: 'string' }, { name: 'sendBody', type: 'boolean' }]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toMatchObject({ type: 'invalid_value', property: 'url', message: 'URL must start with http:// or https://' });
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
expect(result.warnings[0]).toMatchObject({ type: 'missing_common', property: 'sendBody', message: 'POST requests typically send a body' });
|
||||
expect(result.autofix).toMatchObject({ sendBody: true, contentType: 'json' });
|
||||
});
|
||||
it('should validate JSON in HTTP Request body', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.httpRequest', { method: 'POST', url: 'https://api.example.com', contentType: 'json', body: '{"invalid": json}' }, [{ name: 'method', type: 'options' }, { name: 'url', type: 'string' }, { name: 'contentType', type: 'options' }, { name: 'body', type: 'string' }]);
|
||||
expect(result.errors.some(e => e.property === 'body' && e.message.includes('Invalid JSON')));
|
||||
});
|
||||
it('should handle webhook-specific validation', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.webhook', { httpMethod: 'GET', path: 'webhook-endpoint' }, [{ name: 'httpMethod', type: 'options' }, { name: 'path', type: 'string' }]);
|
||||
expect(result.warnings.some(w => w.property === 'path' && w.message.includes('should start with /')));
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Code Node (from node-specific) ──────────────────────────────
|
||||
|
||||
describe('Code node validation', () => {
|
||||
it('should validate Code node configurations', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.code', { language: 'javascript', jsCode: '' }, [{ name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' }]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0]).toMatchObject({ type: 'missing_required', property: 'jsCode', message: 'Code cannot be empty' });
|
||||
});
|
||||
it('should validate JavaScript syntax in Code node', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.code', { language: 'javascript', jsCode: 'const data = { foo: "bar" };\nif (data.foo {\n return [{json: data}];\n}' }, [{ name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' }]);
|
||||
expect(result.errors.some(e => e.message.includes('Unbalanced')));
|
||||
expect(result.warnings).toHaveLength(1);
|
||||
});
|
||||
it('should validate n8n-specific patterns in Code node', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.code', { language: 'javascript', jsCode: 'const processedData = items.map(item => ({...item.json, processed: true}));' }, [{ name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' }]);
|
||||
expect(result.warnings.some(w => w.type === 'missing_common' && w.message.includes('No return statement found'))).toBe(true);
|
||||
});
|
||||
it('should handle empty code in Code node', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.code', { language: 'javascript', jsCode: ' \n \t \n ' }, [{ name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' }]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.type === 'missing_required' && e.message.includes('Code cannot be empty'))).toBe(true);
|
||||
});
|
||||
it('should validate complex return patterns in Code node', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.code', { language: 'javascript', jsCode: 'return ["string1", "string2", "string3"];' }, [{ name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' }]);
|
||||
expect(result.warnings.some(w => w.type === 'invalid_value' && w.message.includes('Items must be objects with json property'))).toBe(true);
|
||||
});
|
||||
it('should validate Code node with $helpers usage', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.code', { language: 'javascript', jsCode: 'const workflow = $helpers.getWorkflowStaticData();\nworkflow.counter = (workflow.counter || 0) + 1;\nreturn [{json: {count: workflow.counter}}];' }, [{ name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' }]);
|
||||
expect(result.warnings.some(w => w.type === 'best_practice' && w.message.includes('$helpers is only available in Code nodes'))).toBe(true);
|
||||
});
|
||||
it('should detect incorrect $helpers.getWorkflowStaticData usage', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.code', { language: 'javascript', jsCode: 'const data = $helpers.getWorkflowStaticData;\nreturn [{json: {data}}];' }, [{ name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' }]);
|
||||
expect(result.errors.some(e => e.type === 'invalid_value' && e.message.includes('getWorkflowStaticData requires parentheses'))).toBe(true);
|
||||
});
|
||||
it('should validate console.log usage', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.code', { language: 'javascript', jsCode: "console.log('Debug info:', items);\nreturn items;" }, [{ name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' }]);
|
||||
expect(result.warnings.some(w => w.type === 'best_practice' && w.message.includes('console.log output appears in n8n execution logs'))).toBe(true);
|
||||
});
|
||||
it('should validate $json usage warning', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.code', { language: 'javascript', jsCode: 'const data = $json.myField;\nreturn [{json: {processed: data}}];' }, [{ name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' }]);
|
||||
expect(result.warnings.some(w => w.type === 'best_practice' && w.message.includes('$json only works in "Run Once for Each Item" mode'))).toBe(true);
|
||||
});
|
||||
it('should not warn about properties for Code nodes', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.code', { language: 'javascript', jsCode: 'return items;', unusedProperty: 'test' }, [{ name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' }]);
|
||||
expect(result.warnings.some(w => w.type === 'inefficient' && w.property === 'unusedProperty')).toBe(false);
|
||||
});
|
||||
it('should suggest error handling for complex code', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.code', { language: 'javascript', jsCode: "const apiUrl = items[0].json.url;\nconst response = await fetch(apiUrl);\nconst data = await response.json();\nreturn [{json: data}];" }, [{ name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' }]);
|
||||
expect(result.suggestions.some(s => s.includes('Consider adding error handling')));
|
||||
});
|
||||
it('should suggest error handling for non-trivial code', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.code', { language: 'javascript', jsCode: Array(10).fill('const x = 1;').join('\n') + '\nreturn items;' }, [{ name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' }]);
|
||||
expect(result.suggestions.some(s => s.includes('error handling')));
|
||||
});
|
||||
it('should validate async operations without await', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.code', { language: 'javascript', jsCode: "const promise = fetch('https://api.example.com');\nreturn [{json: {data: promise}}];" }, [{ name: 'language', type: 'options' }, { name: 'jsCode', type: 'string' }]);
|
||||
expect(result.warnings.some(w => w.type === 'best_practice' && w.message.includes('Async operation without await'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Python Code Node (from node-specific) ──────────────────────
|
||||
|
||||
describe('Python Code node validation', () => {
|
||||
it('should validate Python code syntax', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.code', { language: 'python', pythonCode: 'def process_data():\n return [{"json": {"test": True}]' }, [{ name: 'language', type: 'options' }, { name: 'pythonCode', type: 'string' }]);
|
||||
expect(result.errors.some(e => e.type === 'syntax_error' && e.message.includes('Unmatched bracket'))).toBe(true);
|
||||
});
|
||||
it('should detect mixed indentation in Python code', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.code', { language: 'python', pythonCode: 'def process():\n x = 1\n\ty = 2\n return [{"json": {"x": x, "y": y}}]' }, [{ name: 'language', type: 'options' }, { name: 'pythonCode', type: 'string' }]);
|
||||
expect(result.errors.some(e => e.type === 'syntax_error' && e.message.includes('Mixed indentation'))).toBe(true);
|
||||
});
|
||||
it('should warn about incorrect n8n return patterns', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.code', { language: 'python', pythonCode: 'result = {"data": "value"}\nreturn result' }, [{ name: 'language', type: 'options' }, { name: 'pythonCode', type: 'string' }]);
|
||||
expect(result.warnings.some(w => w.type === 'invalid_value' && w.message.includes('Must return array of objects with json key'))).toBe(true);
|
||||
});
|
||||
it('should warn about using external libraries in Python code', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.code', { language: 'python', pythonCode: 'import pandas as pd\nimport requests\ndf = pd.DataFrame(items)\nresponse = requests.get("https://api.example.com")\nreturn [{"json": {"data": response.json()}}]' }, [{ name: 'language', type: 'options' }, { name: 'pythonCode', type: 'string' }]);
|
||||
expect(result.warnings.some(w => w.type === 'invalid_value' && w.message.includes('External libraries not available'))).toBe(true);
|
||||
});
|
||||
it('should validate Python code with print statements', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.code', { language: 'python', pythonCode: 'print("Debug:", items)\nprocessed = []\nfor item in items:\n print(f"Processing: {item}")\n processed.append({"json": item["json"]})\nreturn processed' }, [{ name: 'language', type: 'options' }, { name: 'pythonCode', type: 'string' }]);
|
||||
expect(result.warnings.some(w => w.type === 'best_practice' && w.message.includes('print() output appears in n8n execution logs'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Database Node (from node-specific, non-security) ────────────
|
||||
|
||||
describe('Database node validation', () => {
|
||||
it('should validate SQL SELECT * performance warning', () => {
|
||||
const result = ConfigValidator.validate('nodes-base.postgres', { query: 'SELECT * FROM large_table WHERE status = "active"' }, [{ name: 'query', type: 'string' }]);
|
||||
expect(result.suggestions.some(s => s.includes('Consider selecting specific columns'))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user